argus-reviewer-e2e 0.2.0 → 0.3.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 +77 -64
- package/action/action.yml +143 -66
- package/action/approval-review.mjs +393 -0
- package/action/bootstrap.mjs +102 -0
- package/action/cli.mjs +36 -0
- package/action/emit-review.mjs +336 -0
- package/action/runtime.mjs +154 -0
- package/action/{sticky-comment.mjs → sticky-comment.cjs} +360 -74
- package/dist/api.d.ts +1 -0
- package/dist/api.js +3 -0
- package/dist/cache/store.d.ts +2 -0
- package/dist/cache/store.js +10 -3
- package/dist/cli.d.ts +80 -10
- package/dist/cli.js +296 -13
- package/dist/config.d.ts +4 -0
- package/dist/config.js +32 -4
- package/dist/engine/loop.d.ts +12 -0
- package/dist/engine/loop.js +55 -6
- package/dist/pipeline/budget.d.ts +11 -0
- package/dist/pipeline/budget.js +48 -0
- package/dist/pipeline/contracts.d.ts +12 -0
- package/dist/pipeline/contracts.js +16 -0
- package/dist/pipeline/verify.d.ts +27 -0
- package/dist/pipeline/verify.js +197 -0
- package/dist/report/manifest.d.ts +87 -0
- package/dist/report/manifest.js +143 -0
- package/dist/report/run.d.ts +9 -0
- package/dist/report/run.js +6 -0
- package/dist/review/secrets.d.ts +2 -0
- package/dist/review/secrets.js +4 -0
- package/package.json +7 -7
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
|
|
6
|
+
// The composite action injects these objects at runtime. Keeping them in a
|
|
7
|
+
// module-level runtime binding lets the GitHub script load this file with a
|
|
8
|
+
// normal require() instead of evaluating its source dynamically.
|
|
9
|
+
let github
|
|
10
|
+
let context
|
|
11
|
+
let core
|
|
12
|
+
|
|
6
13
|
const SENTINEL = '<!-- argus-reviewer -->'
|
|
7
14
|
|
|
8
15
|
function formatUsd(n) {
|
|
@@ -64,12 +71,17 @@ function renderBody(report, codeReview, runUrl, ok, inlinePlan) {
|
|
|
64
71
|
lines.push('')
|
|
65
72
|
lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
|
|
66
73
|
lines.push('')
|
|
74
|
+
pushReviewTop(lines, codeReview)
|
|
67
75
|
lines.push(
|
|
68
76
|
`**Summary:** ${report.totals.passed}/${report.totals.tests} passed · ` +
|
|
69
77
|
`${report.totals.visionCalls} vision calls · ` +
|
|
70
78
|
`${formatUsd(report.totals.visionCostUsd)} spend · ` +
|
|
71
79
|
`${report.totals.sandboxSeconds.toFixed(1)}s sandbox`,
|
|
72
80
|
)
|
|
81
|
+
lines.push(
|
|
82
|
+
`**Fingerprint cache:** ${report.totals.cacheHits ?? 0} hit(s) · ` +
|
|
83
|
+
`${report.totals.cacheMisses ?? 0} miss(es) · ${report.totals.cacheHeals ?? 0} heal(s)`,
|
|
84
|
+
)
|
|
73
85
|
lines.push('')
|
|
74
86
|
|
|
75
87
|
lines.push('<details>')
|
|
@@ -232,6 +244,59 @@ function renderBody(report, codeReview, runUrl, ok, inlinePlan) {
|
|
|
232
244
|
return lines.join('\n')
|
|
233
245
|
}
|
|
234
246
|
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// R6 — scannable top block under the sentinel: verdict icon + one-line
|
|
249
|
+
// summary + honest counts, so the first screen answers "verdict, what
|
|
250
|
+
// kinds, what needs me" before the <details> fold. ⛔ counts
|
|
251
|
+
// probe-reproduced findings only — a high p alone is never "proven" —
|
|
252
|
+
// while ◎ carries the Jev-confidence count; the two overlap when a
|
|
253
|
+
// finding is both (KTD2). The serialized blocker counts win when present
|
|
254
|
+
// (same numbers reviewBody prints); the recount is the fallback for
|
|
255
|
+
// reports predating them — P_FALLBACK_GATE must match
|
|
256
|
+
// P_TRUE_POSITIVE_THRESHOLD in src/cli.ts.
|
|
257
|
+
|
|
258
|
+
const REVIEW_VERDICT_ICON = { pass: '✅', approve: '👍', needs_changes: '🔴' }
|
|
259
|
+
const P_FALLBACK_GATE = 0.7
|
|
260
|
+
|
|
261
|
+
function pushReviewTop(lines, codeReview) {
|
|
262
|
+
if (!codeReview || codeReview.skipped) return
|
|
263
|
+
const findings = Array.isArray(codeReview.findings) ? codeReview.findings : []
|
|
264
|
+
const icon = REVIEW_VERDICT_ICON[codeReview.verdict] ?? '❔'
|
|
265
|
+
lines.push(
|
|
266
|
+
`**Code review:** ${icon} **${cell(codeReview.verdict ?? 'unknown')}** — ${cell(codeReview.summary)}`,
|
|
267
|
+
)
|
|
268
|
+
if (findings.length === 0) {
|
|
269
|
+
lines.push('no findings')
|
|
270
|
+
lines.push('')
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const sev = { bug: 0, risk: 0, nit: 0, q: 0 }
|
|
274
|
+
for (const f of findings) if (Object.hasOwn(sev, f.severity)) sev[f.severity] += 1
|
|
275
|
+
const tail = []
|
|
276
|
+
// 🔧 counts serialized comments carrying a committable block — what
|
|
277
|
+
// actually lands on the PR — not every finding the model offered a
|
|
278
|
+
// patch for (overflowed/ineligible suggestions aren't committable).
|
|
279
|
+
const suggestions = Array.isArray(codeReview.reviewComments)
|
|
280
|
+
? codeReview.reviewComments.filter((c) => extractSuggestion(c.body ?? '') !== '').length
|
|
281
|
+
: findings.filter((f) => typeof f.suggestion === 'string' && f.suggestion !== '').length
|
|
282
|
+
if (suggestions > 0) tail.push(`🔧 ${suggestions} suggestion${suggestions === 1 ? '' : 's'}`)
|
|
283
|
+
const reproduced =
|
|
284
|
+
typeof codeReview.provenBlockers === 'number'
|
|
285
|
+
? codeReview.provenBlockers
|
|
286
|
+
: findings.filter((f) => f.evidence?.status === 'reproduced').length
|
|
287
|
+
if (reproduced > 0) tail.push(`⛔ ${reproduced} reproduced`)
|
|
288
|
+
const confident =
|
|
289
|
+
typeof codeReview.highConfidenceBlockers === 'number'
|
|
290
|
+
? codeReview.highConfidenceBlockers
|
|
291
|
+
: findings.filter((f) => typeof f.p === 'number' && f.p >= P_FALLBACK_GATE).length
|
|
292
|
+
if (confident > 0) tail.push(`◎ ${confident} high-confidence`)
|
|
293
|
+
lines.push(
|
|
294
|
+
`🐛 ${sev.bug} · ⚠️ ${sev.risk} · 💡 ${sev.nit} · ❓ ${sev.q}` +
|
|
295
|
+
(tail.length > 0 ? ` — ${tail.join(' · ')}` : ''),
|
|
296
|
+
)
|
|
297
|
+
lines.push('')
|
|
298
|
+
}
|
|
299
|
+
|
|
235
300
|
// The 🧠 Code review details block — shared by the full body and the
|
|
236
301
|
// review-only body (run lane disabled).
|
|
237
302
|
function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
|
|
@@ -242,6 +307,11 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
|
|
|
242
307
|
lines.push(
|
|
243
308
|
`**Verdict:** ${codeReview.verdict} · ${codeReview.model} · ${codeReview.tokens}tok ${formatUsd(codeReview.visionCostUsd)}`,
|
|
244
309
|
)
|
|
310
|
+
if (codeReview.headBinding) {
|
|
311
|
+
lines.push(
|
|
312
|
+
`**Head binding:** ${cell(codeReview.headBinding.status)} · ${cell(codeReview.headBinding.detail)}`,
|
|
313
|
+
)
|
|
314
|
+
}
|
|
245
315
|
// U7 triage record — Jev annotate/route signals, never the gate.
|
|
246
316
|
if (codeReview.triage) {
|
|
247
317
|
const t = codeReview.triage
|
|
@@ -298,14 +368,6 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
|
|
|
298
368
|
)
|
|
299
369
|
}
|
|
300
370
|
lines.push('')
|
|
301
|
-
// Inline-comment cap note — dedup'd fresh findings the
|
|
302
|
-
// review.maxComments budget didn't post (TCA max_comments).
|
|
303
|
-
if (inlinePlan !== undefined && inlinePlan.dropped > 0) {
|
|
304
|
-
lines.push(
|
|
305
|
-
`*+${inlinePlan.dropped} inline-eligible finding(s) not posted — \`review.maxComments\` cap ${inlinePlan.cap}.*`,
|
|
306
|
-
)
|
|
307
|
-
lines.push('')
|
|
308
|
-
}
|
|
309
371
|
// Secrets-lane audit line — adjudicated/suppressed counts, never literals.
|
|
310
372
|
if (codeReview.secretsScan) {
|
|
311
373
|
if (typeof codeReview.secretsScan.skipped === 'string') {
|
|
@@ -323,6 +385,20 @@ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
|
|
|
323
385
|
lines.push('')
|
|
324
386
|
}
|
|
325
387
|
}
|
|
388
|
+
// Serialized comments that didn't post — the maxComments cap
|
|
389
|
+
// (serialized overflow) plus post-time drops (off-diff anchors,
|
|
390
|
+
// retry-ladder discards). Outside the findings guard: drops still
|
|
391
|
+
// disclose even when the findings table rendered nothing.
|
|
392
|
+
if (inlinePlan !== undefined && inlinePlan.dropped > 0) {
|
|
393
|
+
const overflow = inlinePlan.overflow ?? 0
|
|
394
|
+
const reasons = []
|
|
395
|
+
if (overflow > 0) reasons.push(`\`review.maxComments\` cap ${inlinePlan.cap}`)
|
|
396
|
+
if (inlinePlan.dropped - overflow > 0) {
|
|
397
|
+
reasons.push(`${inlinePlan.dropped - overflow} outside the PR diff`)
|
|
398
|
+
}
|
|
399
|
+
lines.push(`*+${inlinePlan.dropped} inline comment(s) not posted — ${reasons.join(', ')}.*`)
|
|
400
|
+
lines.push('')
|
|
401
|
+
}
|
|
326
402
|
// U8 adjudication audit — outside the findings guard so suppressed-
|
|
327
403
|
// only reviews still show what Jev removed. p values live on the
|
|
328
404
|
// findings table and in code-review.json records.
|
|
@@ -354,6 +430,7 @@ function renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan) {
|
|
|
354
430
|
lines.push('')
|
|
355
431
|
lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
|
|
356
432
|
lines.push('')
|
|
433
|
+
pushReviewTop(lines, codeReview)
|
|
357
434
|
if (!codeReview) {
|
|
358
435
|
lines.push(
|
|
359
436
|
'**Summary:** code-review only (run lane disabled) — no `code-review.json` found. The review step crashed or produced no report; the commit status fails closed — check the action logs before merging.',
|
|
@@ -406,80 +483,271 @@ function renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan) {
|
|
|
406
483
|
return lines.join('\n')
|
|
407
484
|
}
|
|
408
485
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// U3 — the review poster is deliberately dumb (KTD3): the CLI serializes the
|
|
488
|
+
// whole surface into code-review.json (`reviewComments[]` arrives
|
|
489
|
+
// eligibility-filtered, sanitized, severity-sorted, capped, keyed). This file
|
|
490
|
+
// only: freshness-gates the report (R9), dedups against posted comments (R10),
|
|
491
|
+
// validates anchors against the live diff (R8), dismisses stale self-reviews
|
|
492
|
+
// (KTD5), and POSTs one batched review with the serialized event plus a
|
|
493
|
+
// bounded retry ladder (R4/KTD4).
|
|
494
|
+
|
|
495
|
+
/** djb2 → 8 hex chars. Must match shortHash() in src/cli.ts — the CLI's
|
|
496
|
+
* dedupKey suffix is this hash over the raw suggestion text. */
|
|
497
|
+
function shortHash(s) {
|
|
498
|
+
let h = 5381
|
|
499
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
|
|
500
|
+
return (h >>> 0).toString(16).padStart(8, '0')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Pull the fenced ```` ```suggestion ```` block out of a posted comment body.
|
|
504
|
+
* The CLI's fence is longest-backtick-run+1 (min 4), so a run of exactly the
|
|
505
|
+
* fence's length can only appear as the closing fence — the backreference is
|
|
506
|
+
* safe against interior ``` runs. */
|
|
507
|
+
function extractSuggestion(body) {
|
|
508
|
+
const m = /\r?\n(`{4,})suggestion\r?\n([\s\S]*?)\r?\n\1/.exec(body)
|
|
509
|
+
return m === null ? '' : m[2]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Reconstruct the R10 dedupKey for an already-posted review comment:
|
|
513
|
+
* `path:line:bodyFirstLine:hash8(suggestion|'')` — identical to the key the
|
|
514
|
+
* CLI serialized, so a corrected suggestion re-posts instead of colliding. */
|
|
515
|
+
function postedDedupKey(c) {
|
|
516
|
+
const body = c.body ?? ''
|
|
517
|
+
return `${c.path}:${c.line}:${body.split('\n')[0]}:${shortHash(extractSuggestion(body))}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Fetch every page of a list endpoint (100/page, octokit shape). */
|
|
521
|
+
async function listAll(fn, params) {
|
|
522
|
+
const out = []
|
|
440
523
|
let page = 1
|
|
441
524
|
for (;;) {
|
|
442
|
-
const { data
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
525
|
+
const { data } = await fn({ ...params, per_page: 100, page })
|
|
526
|
+
if (!Array.isArray(data) || data.length === 0) break
|
|
527
|
+
out.push(...data)
|
|
528
|
+
if (data.length < 100) break
|
|
529
|
+
page += 1
|
|
530
|
+
}
|
|
531
|
+
return out
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The GITHUB_TOKEN posts as `github-actions[bot]`; a custom token may post
|
|
535
|
+
* as its own login or `<actor>[bot]`. Match that set so stale *self* reviews
|
|
536
|
+
* are dismissed without ever touching a human reviewer's verdict. */
|
|
537
|
+
function isSelfLogin(login) {
|
|
538
|
+
const actor = context.actor
|
|
539
|
+
return (
|
|
540
|
+
login === 'github-actions[bot]' ||
|
|
541
|
+
login === actor ||
|
|
542
|
+
(typeof actor === 'string' && actor !== '' && login === `${actor}[bot]`)
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** RIGHT-side line numbers covered by a unified-diff patch. Every line in a
|
|
547
|
+
* hunk's `+c,d` range is a valid RIGHT-side anchor (context or added); `-`
|
|
548
|
+
* lines aren't counted in `d`, so the range is contiguous. */
|
|
549
|
+
function rightSideLines(patch) {
|
|
550
|
+
const lines = new Set()
|
|
551
|
+
for (const m of patch.matchAll(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/g)) {
|
|
552
|
+
const start = Number.parseInt(m[1], 10)
|
|
553
|
+
const count = m[2] === undefined ? 1 : Number.parseInt(m[2], 10)
|
|
554
|
+
for (let l = start; l < start + count; l++) lines.add(l)
|
|
555
|
+
}
|
|
556
|
+
return lines
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** True when a serialized comment anchors inside the live diff — path in the
|
|
560
|
+
* file list and `line` (plus `start_line` when present) on a RIGHT-side
|
|
561
|
+
* hunk line. Files without a `patch` (large/binary) accept no comments. */
|
|
562
|
+
function isOnDiff(c, diffLines) {
|
|
563
|
+
const valid = diffLines.get(c.path)
|
|
564
|
+
return (
|
|
565
|
+
valid !== undefined &&
|
|
566
|
+
valid.has(c.line) &&
|
|
567
|
+
(c.start_line === undefined || valid.has(c.start_line))
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function planInlineComments(pr, codeReview) {
|
|
572
|
+
if (!pr || !codeReview || codeReview.skipped) return undefined
|
|
573
|
+
// Old-format reports carry no serialized surface — degrade to sticky +
|
|
574
|
+
// status only, exactly as before U3. No review post, no API calls.
|
|
575
|
+
if (!Array.isArray(codeReview.reviewComments)) return undefined
|
|
576
|
+
|
|
577
|
+
// R9/KTD6 freshness — a planted or stale report must never produce
|
|
578
|
+
// committable suggestions or a blocking review. The sticky still posts;
|
|
579
|
+
// it renders status text, not code.
|
|
580
|
+
if (codeReview.headBinding?.intendedSha !== pr.head.sha) {
|
|
581
|
+
core.warning(
|
|
582
|
+
`code-review.json head binding ` +
|
|
583
|
+
`(${codeReview.headBinding?.intendedSha ?? 'missing'}) does not match ` +
|
|
584
|
+
`PR head ${pr.head.sha} — skipping inline review`,
|
|
585
|
+
)
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const cap = typeof codeReview.maxComments === 'number' ? codeReview.maxComments : 20
|
|
590
|
+
const overflow = typeof codeReview.commentsOverflow === 'number' ? codeReview.commentsOverflow : 0
|
|
591
|
+
const prRef = {
|
|
592
|
+
owner: context.repo.owner,
|
|
593
|
+
repo: context.repo.repo,
|
|
594
|
+
pull_number: pr.number,
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// List failures degrade to sticky-only (warn), never crash main() before
|
|
598
|
+
// the sticky posts.
|
|
599
|
+
try {
|
|
600
|
+
// R10 dedup — paginate fully and scope to the current head so comments on
|
|
601
|
+
// older commits can't suppress still-valid findings. Keys are
|
|
602
|
+
// reconstructed from the posted body (first line + hash of the embedded
|
|
603
|
+
// suggestion), so a re-run with a corrected suggestion posts the fix
|
|
604
|
+
// instead of colliding.
|
|
605
|
+
const posted = new Set()
|
|
606
|
+
const existing = await listAll((p) => github.rest.pulls.listReviewComments(p), prRef)
|
|
449
607
|
for (const c of existing) {
|
|
450
|
-
if (
|
|
451
|
-
|
|
608
|
+
if (
|
|
609
|
+
c.commit_id === pr.head.sha &&
|
|
610
|
+
typeof c.body === 'string' &&
|
|
611
|
+
c.body.startsWith('**argus-reviewer')
|
|
612
|
+
) {
|
|
613
|
+
posted.add(postedDedupKey(c))
|
|
452
614
|
}
|
|
453
615
|
}
|
|
454
|
-
|
|
455
|
-
|
|
616
|
+
const fresh = codeReview.reviewComments.filter((c) => !posted.has(c.dedupKey))
|
|
617
|
+
|
|
618
|
+
// R8 live-diff validation — the diff is authoritative only at post time;
|
|
619
|
+
// drop anchors that aren't RIGHT-side lines in the current PR diff.
|
|
620
|
+
const files = await listAll((p) => github.rest.pulls.listFiles(p), prRef)
|
|
621
|
+
const diffLines = new Map()
|
|
622
|
+
for (const f of files) {
|
|
623
|
+
if (typeof f.patch === 'string') diffLines.set(f.filename, rightSideLines(f.patch))
|
|
624
|
+
}
|
|
625
|
+
const comments = fresh.filter((c) => isOnDiff(c, diffLines))
|
|
626
|
+
const offDiff = fresh.length - comments.length
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
comments,
|
|
630
|
+
diffLines,
|
|
631
|
+
dropped: overflow + offDiff,
|
|
632
|
+
cap,
|
|
633
|
+
overflow,
|
|
634
|
+
offDiff,
|
|
635
|
+
event: codeReview.reviewEvent === 'request_changes' ? 'REQUEST_CHANGES' : 'COMMENT',
|
|
636
|
+
verdict: codeReview.verdict,
|
|
637
|
+
provenBlockers: codeReview.provenBlockers ?? 0,
|
|
638
|
+
highConfidenceBlockers: codeReview.highConfidenceBlockers ?? 0,
|
|
639
|
+
}
|
|
640
|
+
} catch (e) {
|
|
641
|
+
core.warning(`review planning failed: ${e.message} — sticky still posts`)
|
|
642
|
+
return undefined
|
|
456
643
|
}
|
|
457
|
-
|
|
644
|
+
}
|
|
458
645
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
646
|
+
/** Review body: verdict line + honest blocker counts (R6 — reproduced and
|
|
647
|
+
* p-gated are never lumped). Always present — REQUEST_CHANGES requires a
|
|
648
|
+
* body. Carries the sentinel so KTD5 dismissal can self-identify. */
|
|
649
|
+
function reviewBody(plan, note) {
|
|
650
|
+
const parts = [`verdict **${plan.verdict ?? 'unknown'}**`]
|
|
651
|
+
if (plan.provenBlockers > 0) {
|
|
652
|
+
parts.push(`⛔ ${plan.provenBlockers} reproduced blocker(s)`)
|
|
653
|
+
}
|
|
654
|
+
if (plan.highConfidenceBlockers > 0) {
|
|
655
|
+
parts.push(`◎ ${plan.highConfidenceBlockers} high-confidence blocker(s)`)
|
|
656
|
+
}
|
|
657
|
+
let body = `${SENTINEL}\n**argus-reviewer** — ${parts.join(' · ')}`
|
|
658
|
+
if (note !== undefined) body += `\n\n*${note}*`
|
|
659
|
+
return body
|
|
465
660
|
}
|
|
466
661
|
|
|
467
662
|
async function postInlineComments(pr, plan) {
|
|
468
|
-
if (plan === undefined
|
|
469
|
-
|
|
470
|
-
|
|
663
|
+
if (plan === undefined) return
|
|
664
|
+
const owner = context.repo.owner
|
|
665
|
+
const repo = context.repo.repo
|
|
666
|
+
|
|
667
|
+
// KTD5 — dismiss stale self reviews before posting so a fixed PR is never
|
|
668
|
+
// left gated by an obsolete REQUEST_CHANGES. Prior argus reviews carry the
|
|
669
|
+
// sentinel in their body; an empty-bodied PENDING draft is ours by
|
|
670
|
+
// authorship. Dismissal failure warns but never blocks the post.
|
|
471
671
|
try {
|
|
472
|
-
await github.rest.pulls.
|
|
473
|
-
owner
|
|
474
|
-
repo
|
|
672
|
+
const reviews = await listAll((p) => github.rest.pulls.listReviews(p), {
|
|
673
|
+
owner,
|
|
674
|
+
repo,
|
|
475
675
|
pull_number: pr.number,
|
|
476
|
-
commit_id: pr.head.sha,
|
|
477
|
-
event: 'COMMENT',
|
|
478
|
-
comments: plan.capped,
|
|
479
676
|
})
|
|
677
|
+
for (const r of reviews) {
|
|
678
|
+
const stale =
|
|
679
|
+
(r.state === 'CHANGES_REQUESTED' || r.state === 'PENDING') &&
|
|
680
|
+
isSelfLogin(r.user?.login) &&
|
|
681
|
+
(typeof r.body !== 'string' || r.body === '' || r.body.includes(SENTINEL))
|
|
682
|
+
if (!stale) continue
|
|
683
|
+
try {
|
|
684
|
+
await github.rest.pulls.dismissReview({
|
|
685
|
+
owner,
|
|
686
|
+
repo,
|
|
687
|
+
pull_number: pr.number,
|
|
688
|
+
review_id: r.id,
|
|
689
|
+
message: 'Superseded by a newer argus-reviewer review.',
|
|
690
|
+
})
|
|
691
|
+
} catch (e) {
|
|
692
|
+
core.warning(`failed to dismiss stale review ${r.id}: ${e.message}`)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
480
695
|
} catch (e) {
|
|
481
|
-
core.warning(`
|
|
696
|
+
core.warning(`failed to list prior reviews for dismissal: ${e.message}`)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// A COMMENT review with nothing to say posts nothing — the sticky already
|
|
700
|
+
// carries the verdict. REQUEST_CHANGES posts even with zero comments: the
|
|
701
|
+
// gate intent must land.
|
|
702
|
+
if (plan.event !== 'REQUEST_CHANGES' && plan.comments.length === 0) return
|
|
703
|
+
|
|
704
|
+
// dedupKey is poster-local — the API gets path/line/side/body
|
|
705
|
+
// (+start_line/start_side) verbatim.
|
|
706
|
+
const toGh = (c) => {
|
|
707
|
+
const { dedupKey: _dedupKey, ...rest } = c
|
|
708
|
+
return rest
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// KTD4 bounded retry ladder — at most three createReview calls:
|
|
712
|
+
// (1) the serialized event; (2) on 403/422 (own-PR, permissions), COMMENT
|
|
713
|
+
// with a downgrade note in the body; (3) on a comment-caused 422, drop
|
|
714
|
+
// anchors failing diff membership and retry. Then warn and stop — never a
|
|
715
|
+
// per-comment fallback loop.
|
|
716
|
+
let event = plan.event
|
|
717
|
+
let note
|
|
718
|
+
let comments = plan.comments
|
|
719
|
+
let lastErr
|
|
720
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
721
|
+
try {
|
|
722
|
+
await github.rest.pulls.createReview({
|
|
723
|
+
owner,
|
|
724
|
+
repo,
|
|
725
|
+
pull_number: pr.number,
|
|
726
|
+
commit_id: pr.head.sha,
|
|
727
|
+
event,
|
|
728
|
+
body: reviewBody(plan, note),
|
|
729
|
+
comments: comments.map(toGh),
|
|
730
|
+
})
|
|
731
|
+
return
|
|
732
|
+
} catch (e) {
|
|
733
|
+
lastErr = e
|
|
734
|
+
if (attempt === 0 && event === 'REQUEST_CHANGES' && (e.status === 403 || e.status === 422)) {
|
|
735
|
+
event = 'COMMENT'
|
|
736
|
+
note = `REQUEST_CHANGES downgraded to COMMENT — ${e.status} ${e.message}`
|
|
737
|
+
continue
|
|
738
|
+
}
|
|
739
|
+
if (e.status === 422 && comments.length > 0) {
|
|
740
|
+
const kept = comments.filter((c) => isOnDiff(c, plan.diffLines))
|
|
741
|
+
if (kept.length < comments.length) {
|
|
742
|
+
plan.dropped += comments.length - kept.length
|
|
743
|
+
comments = kept
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
break
|
|
748
|
+
}
|
|
482
749
|
}
|
|
750
|
+
core.warning(`review post failed: ${lastErr?.message ?? 'unknown error'} — sticky still posts`)
|
|
483
751
|
}
|
|
484
752
|
|
|
485
753
|
async function main() {
|
|
@@ -521,7 +789,13 @@ async function main() {
|
|
|
521
789
|
const runDisabled = process.env.ARGUS_RUN_DISABLED === '1'
|
|
522
790
|
const ok = (runDisabled || report?.ok === true) && codeReviewOk
|
|
523
791
|
const conclusion = !hasKey ? 'neutral' : ok ? 'success' : 'failure'
|
|
792
|
+
// Freshness + dedup + diff validation for the serialized review surface,
|
|
793
|
+
// computed before the sticky body renders so the "+N not posted" note is
|
|
794
|
+
// truthful. The review posts BEFORE the sticky so retry-ladder drops land
|
|
795
|
+
// in that note too; a plan of undefined (old-format or stale report) makes
|
|
796
|
+
// postInlineComments a no-op with no API calls.
|
|
524
797
|
const inlinePlan = hasKey ? await planInlineComments(pr, codeReview) : undefined
|
|
798
|
+
if (pr) await postInlineComments(pr, inlinePlan)
|
|
525
799
|
const body = !hasKey
|
|
526
800
|
? renderMissingKeyBody()
|
|
527
801
|
: runDisabled
|
|
@@ -530,10 +804,6 @@ async function main() {
|
|
|
530
804
|
? renderNoReportBody(reportDir, runUrl)
|
|
531
805
|
: renderBody(report, codeReview, runUrl, ok, inlinePlan)
|
|
532
806
|
|
|
533
|
-
// Eligibility + dedup for inline comments, computed before the sticky
|
|
534
|
-
// body renders so the "+N not posted" note counts the *fresh* set — the
|
|
535
|
-
// cap is applied to fresh, not to raw findings (already-posted comments
|
|
536
|
-
// must not inflate the dropped count).
|
|
537
807
|
if (pr) {
|
|
538
808
|
const { data: comments } = await github.rest.issues.listComments({
|
|
539
809
|
owner,
|
|
@@ -557,7 +827,6 @@ async function main() {
|
|
|
557
827
|
body,
|
|
558
828
|
})
|
|
559
829
|
}
|
|
560
|
-
await postInlineComments(pr, inlinePlan)
|
|
561
830
|
}
|
|
562
831
|
|
|
563
832
|
const sha = pr ? pr.head.sha : context.sha
|
|
@@ -581,4 +850,21 @@ async function main() {
|
|
|
581
850
|
core.setOutput('conclusion', conclusion)
|
|
582
851
|
}
|
|
583
852
|
|
|
584
|
-
|
|
853
|
+
async function run(runtime) {
|
|
854
|
+
github = runtime.github
|
|
855
|
+
context = runtime.context
|
|
856
|
+
core = runtime.core
|
|
857
|
+
return main()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
module.exports = {
|
|
861
|
+
run,
|
|
862
|
+
renderBody,
|
|
863
|
+
renderReviewOnlyBody,
|
|
864
|
+
renderMissingKeyBody,
|
|
865
|
+
renderNoReportBody,
|
|
866
|
+
planInlineComments,
|
|
867
|
+
postInlineComments,
|
|
868
|
+
shortHash,
|
|
869
|
+
postedDedupKey,
|
|
870
|
+
}
|
package/dist/api.d.ts
CHANGED
|
@@ -92,6 +92,7 @@ export declare class TdSession {
|
|
|
92
92
|
private constructor();
|
|
93
93
|
static create(opts: TdSessionOptions): Promise<TdSession>;
|
|
94
94
|
get visionCalls(): number;
|
|
95
|
+
get cacheStats(): import("./engine/loop.js").CacheStats;
|
|
95
96
|
/** Non-fatal anomalies observed by the engine — journaled as evidence. */
|
|
96
97
|
get errorRecords(): ErrorRecord[];
|
|
97
98
|
get failed(): boolean;
|
package/dist/api.js
CHANGED
|
@@ -90,6 +90,9 @@ export class TdSession {
|
|
|
90
90
|
get visionCalls() {
|
|
91
91
|
return this.engine.visionCalls;
|
|
92
92
|
}
|
|
93
|
+
get cacheStats() {
|
|
94
|
+
return this.engine.cacheStats;
|
|
95
|
+
}
|
|
93
96
|
/** Non-fatal anomalies observed by the engine — journaled as evidence. */
|
|
94
97
|
get errorRecords() {
|
|
95
98
|
return this.engine.errorRecords;
|
package/dist/cache/store.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FingerprintRecord } from './fingerprint.js';
|
|
2
|
+
export declare const FLOW_CACHE_SCHEMA_VERSION: 1;
|
|
2
3
|
/** A cached assertion verdict, keyed on (question, page-state hash). */
|
|
3
4
|
export interface CachedAssert {
|
|
4
5
|
question: string;
|
|
@@ -9,6 +10,7 @@ export interface CachedAssert {
|
|
|
9
10
|
model: string | undefined;
|
|
10
11
|
}
|
|
11
12
|
export interface FlowCache {
|
|
13
|
+
schemaVersion?: typeof FLOW_CACHE_SCHEMA_VERSION;
|
|
12
14
|
steps: FingerprintRecord[];
|
|
13
15
|
asserts?: CachedAssert[];
|
|
14
16
|
}
|
package/dist/cache/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { writeAtomicJson } from '../fsutil.js';
|
|
4
|
+
export const FLOW_CACHE_SCHEMA_VERSION = 1;
|
|
4
5
|
function sortKeys(_, value) {
|
|
5
6
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
6
7
|
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
@@ -19,11 +20,17 @@ export async function loadFlow(cacheDir, flowName) {
|
|
|
19
20
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
20
21
|
return undefined;
|
|
21
22
|
}
|
|
22
|
-
const { steps, asserts } = parsed;
|
|
23
|
+
const { schemaVersion, steps, asserts } = parsed;
|
|
23
24
|
if (!Array.isArray(steps)) {
|
|
24
25
|
return undefined;
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
if (schemaVersion !== undefined && schemaVersion !== FLOW_CACHE_SCHEMA_VERSION) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const out = {
|
|
31
|
+
...(schemaVersion === FLOW_CACHE_SCHEMA_VERSION ? { schemaVersion } : {}),
|
|
32
|
+
steps: steps,
|
|
33
|
+
};
|
|
27
34
|
if (Array.isArray(asserts))
|
|
28
35
|
out.asserts = asserts;
|
|
29
36
|
return out;
|
|
@@ -37,5 +44,5 @@ export async function loadFlow(cacheDir, flowName) {
|
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
46
|
export async function saveFlow(cacheDir, flowName, steps, asserts) {
|
|
40
|
-
await writeAtomicJson(flowPath(cacheDir, flowName), { steps, asserts: asserts ?? [] }, sortKeys);
|
|
47
|
+
await writeAtomicJson(flowPath(cacheDir, flowName), { schemaVersion: FLOW_CACHE_SCHEMA_VERSION, steps, asserts: asserts ?? [] }, sortKeys);
|
|
41
48
|
}
|