argus-reviewer-e2e 0.1.3 → 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.
Files changed (48) hide show
  1. package/README.md +86 -49
  2. package/action/action.yml +155 -61
  3. package/action/approval-review.mjs +393 -0
  4. package/action/bootstrap.mjs +102 -0
  5. package/action/cli.mjs +36 -0
  6. package/action/emit-review.mjs +336 -0
  7. package/action/runtime.mjs +154 -0
  8. package/action/sticky-comment.cjs +870 -0
  9. package/dist/api.d.ts +2 -0
  10. package/dist/api.js +4 -0
  11. package/dist/cache/store.d.ts +2 -0
  12. package/dist/cache/store.js +10 -3
  13. package/dist/cli.d.ts +137 -0
  14. package/dist/cli.js +677 -60
  15. package/dist/config.d.ts +67 -2
  16. package/dist/config.js +138 -7
  17. package/dist/debug.d.ts +1 -0
  18. package/dist/debug.js +9 -3
  19. package/dist/engine/loop.d.ts +12 -0
  20. package/dist/engine/loop.js +55 -6
  21. package/dist/evidence/ci.d.ts +3 -0
  22. package/dist/evidence/ci.js +3 -1
  23. package/dist/pipeline/budget.d.ts +11 -0
  24. package/dist/pipeline/budget.js +48 -0
  25. package/dist/pipeline/contracts.d.ts +12 -0
  26. package/dist/pipeline/contracts.js +16 -0
  27. package/dist/pipeline/verify.d.ts +27 -0
  28. package/dist/pipeline/verify.js +197 -0
  29. package/dist/probe/queue.d.ts +4 -1
  30. package/dist/probe/queue.js +46 -8
  31. package/dist/report/manifest.d.ts +87 -0
  32. package/dist/report/manifest.js +143 -0
  33. package/dist/report/run.d.ts +9 -0
  34. package/dist/report/run.js +6 -0
  35. package/dist/review/adjudicate.d.ts +63 -0
  36. package/dist/review/adjudicate.js +111 -0
  37. package/dist/review/secrets.d.ts +90 -0
  38. package/dist/review/secrets.js +224 -0
  39. package/dist/review/triage.d.ts +76 -0
  40. package/dist/review/triage.js +163 -0
  41. package/dist/trust.d.ts +50 -0
  42. package/dist/trust.js +103 -0
  43. package/dist/vision/cost.d.ts +14 -1
  44. package/dist/vision/cost.js +13 -0
  45. package/dist/vision/decisions.d.ts +95 -0
  46. package/dist/vision/decisions.js +232 -0
  47. package/package.json +9 -8
  48. package/action/sticky-comment.mjs +0 -404
@@ -0,0 +1,870 @@
1
+ /* eslint-disable @typescript-eslint/no-require-imports */
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
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
+
13
+ const SENTINEL = '<!-- argus-reviewer -->'
14
+
15
+ function formatUsd(n) {
16
+ return `$${(n || 0).toFixed(6)}`
17
+ }
18
+
19
+ /** Escape a report string for one markdown table cell. */
20
+ function cell(s) {
21
+ return String(s ?? '')
22
+ .replace(/\|/g, '\\|')
23
+ .replace(/[\r\n]+/g, ' ')
24
+ .slice(0, 200)
25
+ }
26
+
27
+ function renderMissingKeyBody() {
28
+ const lines = []
29
+ lines.push(SENTINEL)
30
+ lines.push('')
31
+ lines.push('## argus-reviewer ⚪ skipped')
32
+ lines.push('')
33
+ lines.push(
34
+ '`OPENROUTER_API_KEY` is not configured. Add it as a repository or workflow secret to run argus-reviewer.',
35
+ )
36
+ lines.push('')
37
+ lines.push('This status is intentionally neutral, not a failure.')
38
+ lines.push('')
39
+ return lines.join('\n')
40
+ }
41
+
42
+ function renderNoReportBody(reportDir, runUrl) {
43
+ const lines = []
44
+ lines.push(SENTINEL)
45
+ lines.push('')
46
+ lines.push('## argus-reviewer ⚠️ no report')
47
+ lines.push('')
48
+ lines.push(
49
+ `The run step produced no \`run.json\` under \`${reportDir}\`. The commit status fails closed — check the action logs before merging.`,
50
+ )
51
+ lines.push('')
52
+ lines.push(`[View run](${runUrl})`)
53
+ lines.push('')
54
+ return lines.join('\n')
55
+ }
56
+
57
+ function renderBody(report, codeReview, runUrl, ok, inlinePlan) {
58
+ if (!report) return renderMissingKeyBody()
59
+
60
+ const lines = []
61
+ const budgetCap = report.config?.budgetUsd ?? 0
62
+ const healCount = report.tests.reduce((n, t) => n + (t.healEvents?.length ?? 0), 0)
63
+ const assertCount = report.tests.reduce((n, t) => n + (t.asserts?.length ?? 0), 0)
64
+ const assertFails = report.tests.reduce(
65
+ (n, t) => n + (t.asserts?.filter((a) => a.verdict === 'fail').length ?? 0),
66
+ 0,
67
+ )
68
+ const trace = report.trace ?? {}
69
+
70
+ lines.push(SENTINEL)
71
+ lines.push('')
72
+ lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
73
+ lines.push('')
74
+ pushReviewTop(lines, codeReview)
75
+ lines.push(
76
+ `**Summary:** ${report.totals.passed}/${report.totals.tests} passed · ` +
77
+ `${report.totals.visionCalls} vision calls · ` +
78
+ `${formatUsd(report.totals.visionCostUsd)} spend · ` +
79
+ `${report.totals.sandboxSeconds.toFixed(1)}s sandbox`,
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
+ )
85
+ lines.push('')
86
+
87
+ lines.push('<details>')
88
+ lines.push('<summary>📝 Summary</summary>')
89
+ lines.push('')
90
+ lines.push('**What ran**')
91
+ for (const t of report.tests) {
92
+ lines.push(`- \`${path.basename(t.file)}\` — ${t.name}`)
93
+ }
94
+ lines.push('')
95
+ lines.push(
96
+ `**Risk:** ${ok ? 'Low — UI regression tests and code review passed; no heals or failures.' : 'High — investigate failures before merge.'}`,
97
+ )
98
+ lines.push('')
99
+ if (Object.keys(trace).length > 0) {
100
+ lines.push('**Trace**')
101
+ for (const [k, v] of Object.entries(trace)) {
102
+ lines.push(`- ${k}: \`${v}\``)
103
+ }
104
+ lines.push('')
105
+ }
106
+ lines.push('</details>')
107
+ lines.push('')
108
+
109
+ lines.push('<details>')
110
+ lines.push(`<summary>📒 Tests (${report.totals.tests})</summary>`)
111
+ lines.push('')
112
+ lines.push('| Test | Result | Calls | Cost | Heals | Asserts |')
113
+ lines.push('| --- | --- | ---: | ---: | ---: | ---: |')
114
+ for (const t of report.tests) {
115
+ const result = t.ok ? '✅ pass' : '❌ fail'
116
+ lines.push(
117
+ `| ${t.name} | ${result} | ${t.visionCalls} | ${formatUsd(t.visionCostUsd)} | ${t.healEvents?.length ?? 0} | ${t.asserts?.length ?? 0} |`,
118
+ )
119
+ }
120
+ lines.push('')
121
+ lines.push('</details>')
122
+ lines.push('')
123
+
124
+ lines.push('<details>')
125
+ lines.push('<summary>💰 Cost ledger</summary>')
126
+ lines.push('')
127
+ lines.push('| Line item | Value |')
128
+ lines.push('| --- | ---: |')
129
+ lines.push(`| Vision calls | ${report.totals.visionCalls} |`)
130
+ const perCall =
131
+ report.totals.visionCalls > 0
132
+ ? formatUsd(report.totals.visionCostUsd / report.totals.visionCalls)
133
+ : '$0.00'
134
+ lines.push(`| Per-call cost (avg) | ${perCall} |`)
135
+ for (const model of Object.keys(report.totals.callsByModel ?? {}).sort()) {
136
+ lines.push(`| Calls (${model}) | ${report.totals.callsByModel[model]} |`)
137
+ lines.push(`| Spend (${model}) | ${formatUsd(report.totals.costByModel?.[model] ?? 0)} |`)
138
+ }
139
+ lines.push(`| Total vision spend | ${formatUsd(report.totals.visionCostUsd)} |`)
140
+ lines.push(`| Sandbox seconds | ${report.totals.sandboxSeconds.toFixed(1)}s |`)
141
+ if (budgetCap > 0) {
142
+ lines.push(`| Budget cap | ${formatUsd(budgetCap)} |`)
143
+ lines.push(`| Budget exceeded | ${report.totals.budgetExceeded ? '⚠️ yes' : '✅ no'} |`)
144
+ }
145
+ lines.push('')
146
+ lines.push('</details>')
147
+ lines.push('')
148
+
149
+ lines.push('<details>')
150
+ lines.push('<summary>🔧 Heal events</summary>')
151
+ lines.push('')
152
+ const heals = report.tests.flatMap((t) => t.healEvents ?? [])
153
+ if (heals.length === 0) {
154
+ lines.push('No heals this run.')
155
+ } else {
156
+ for (const h of heals) {
157
+ lines.push(`- \`${h.instruction}\` healed with ${h.model || 'unknown model'}`)
158
+ }
159
+ }
160
+ lines.push('')
161
+ lines.push('</details>')
162
+ lines.push('')
163
+
164
+ lines.push('<details>')
165
+ lines.push('<summary>✅ Assertions</summary>')
166
+ lines.push('')
167
+ let any = false
168
+ for (const t of report.tests) {
169
+ if (!t.asserts || t.asserts.length === 0) continue
170
+ any = true
171
+ lines.push(`**${t.name}**`)
172
+ for (const a of t.asserts) {
173
+ const icon = a.verdict === 'pass' ? '✅' : a.verdict === 'fail' ? '❌' : '⚪'
174
+ lines.push(`- ${icon} *${a.question}* — ${a.reasoning}`)
175
+ }
176
+ lines.push('')
177
+ }
178
+ if (!any) {
179
+ lines.push('No assertions recorded.')
180
+ lines.push('')
181
+ }
182
+ lines.push('</details>')
183
+ lines.push('')
184
+
185
+ lines.push('<details>')
186
+ lines.push('<summary>📂 Evidence</summary>')
187
+ lines.push('')
188
+ if (report.artifacts && report.artifacts.videos.length > 0) {
189
+ for (const v of report.artifacts.videos) lines.push(`- video: \`${v}\``)
190
+ }
191
+ if (runUrl) lines.push(`- [workflow run / artifacts](${runUrl})`)
192
+ if ((!report.artifacts || report.artifacts.videos.length === 0) && !runUrl) {
193
+ lines.push('No artifact links available.')
194
+ }
195
+ lines.push('')
196
+ lines.push('</details>')
197
+ lines.push('')
198
+
199
+ lines.push('<details>')
200
+ lines.push('<summary>🚥 Pre-merge checks</summary>')
201
+ lines.push('')
202
+ lines.push('| Check | Status | Explanation |')
203
+ lines.push('| --- | --- | --- |')
204
+ lines.push(
205
+ `| Tests | ${report.ok ? '✅ Passed' : '❌ Failed'} | ${report.totals.passed}/${report.totals.tests} tests passed |`,
206
+ )
207
+ lines.push(
208
+ `| Budget | ${report.totals.budgetExceeded ? '⚠️ Warning' : '✅ Passed'} | ${formatUsd(report.totals.visionCostUsd)} spent${budgetCap > 0 ? ` of ${formatUsd(budgetCap)}` : ''} |`,
209
+ )
210
+ lines.push(
211
+ `| Heal events | ${healCount === 0 ? '✅ Passed' : '⚠️ Warning'} | ${healCount} heal event${healCount === 1 ? '' : 's'} |`,
212
+ )
213
+ lines.push(
214
+ `| Assertions | ${assertFails === 0 ? '✅ Passed' : '❌ Failed'} | ${assertFails === 0 ? assertCount : `${assertFails} failed`} assertion${assertCount === 1 ? '' : 's'} |`,
215
+ )
216
+ lines.push(`| OpenRouter key | ✅ Passed | \`OPENROUTER_API_KEY\` configured |`)
217
+ if (codeReview && !codeReview.skipped) {
218
+ const codeStatus = codeReview.ok ? '✅ Passed' : '❌ Failed'
219
+ lines.push(
220
+ `| Code review | ${codeStatus} | ${codeReview.findings.length} findings (${codeReview.model}) |`,
221
+ )
222
+ } else {
223
+ lines.push(`| Code review | ⚪ Skipped | ${codeReview?.summary ?? 'no report'} |`)
224
+ }
225
+ lines.push('')
226
+ lines.push('</details>')
227
+ lines.push('')
228
+
229
+ pushCodeReviewDetails(lines, codeReview, inlinePlan)
230
+
231
+ lines.push('<details>')
232
+ lines.push('<summary>✨ Actions</summary>')
233
+ lines.push('')
234
+ lines.push('- [ ] Re-run argus-reviewer')
235
+ lines.push('- [ ] Open a heal PR')
236
+ lines.push('- [ ] Record a new flow')
237
+ lines.push('')
238
+ lines.push('</details>')
239
+ lines.push('')
240
+ lines.push('---')
241
+ lines.push('')
242
+ lines.push('<sub>`argus-reviewer` — self-hosted, BYOK OpenRouter UI regression.</sub>')
243
+ lines.push('')
244
+ return lines.join('\n')
245
+ }
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
+
300
+ // The 🧠 Code review details block — shared by the full body and the
301
+ // review-only body (run lane disabled).
302
+ function pushCodeReviewDetails(lines, codeReview, inlinePlan) {
303
+ if (!codeReview || codeReview.skipped) return
304
+ lines.push('<details>')
305
+ lines.push('<summary>🧠 Code review</summary>')
306
+ lines.push('')
307
+ lines.push(
308
+ `**Verdict:** ${codeReview.verdict} · ${codeReview.model} · ${codeReview.tokens}tok ${formatUsd(codeReview.visionCostUsd)}`,
309
+ )
310
+ if (codeReview.headBinding) {
311
+ lines.push(
312
+ `**Head binding:** ${cell(codeReview.headBinding.status)} · ${cell(codeReview.headBinding.detail)}`,
313
+ )
314
+ }
315
+ // U7 triage record — Jev annotate/route signals, never the gate.
316
+ if (codeReview.triage) {
317
+ const t = codeReview.triage
318
+ if (t.unadjudicated === true) {
319
+ lines.push(` · 🧭 triage unadjudicated — Jev unavailable`)
320
+ } else {
321
+ lines.push(
322
+ ` · 🧭 triage: risk ${t.risk ?? '?'}/5` +
323
+ `${typeof t.needsDeepReview === 'number' ? ` · deep-review ${t.needsDeepReview.toFixed(2)}` : ''}` +
324
+ `${t.topRiskArea !== undefined ? ` · top area \`${cell(t.topRiskArea)}\`` : ''}` +
325
+ ` (${cell(t.mode)})`,
326
+ )
327
+ }
328
+ }
329
+ if (Array.isArray(codeReview.probes) && codeReview.probes.length > 0) {
330
+ const reproduced = codeReview.probes.filter((p) => p.outcome === 'reproduced').length
331
+ lines.push(
332
+ ` · 🧪 ${codeReview.probes.length} probe${codeReview.probes.length === 1 ? '' : 's'} run, ${reproduced} reproduced`,
333
+ )
334
+ }
335
+ if (typeof codeReview.probeLaneSkipped === 'string') {
336
+ lines.push(` · 🧪 probe lane skipped — ${cell(codeReview.probeLaneSkipped)}`)
337
+ }
338
+ lines.push('')
339
+ lines.push(codeReview.summary)
340
+ lines.push('')
341
+ if (codeReview.findings.length > 0) {
342
+ const evidenceIcon = {
343
+ exercised: '✅',
344
+ corroborated: '🔴',
345
+ not_exercised: '⚪',
346
+ inconclusive: '❔',
347
+ reproduced: '🧪',
348
+ }
349
+ lines.push('| File | Severity | p | Category | Evidence | Finding |')
350
+ lines.push('| --- | --- | --- | --- | --- | --- |')
351
+ // Findings/evidence strings are model- and probe-emitted — sanitize
352
+ // for the markdown table and bound the section so an oversized report
353
+ // can't push the body past GitHub's 65536-char comment limit.
354
+ const MAX_FINDING_ROWS = 25
355
+ for (const f of codeReview.findings.slice(0, MAX_FINDING_ROWS)) {
356
+ const ev = f.evidence
357
+ ? `${evidenceIcon[f.evidence.status] ?? '❔'} ${cell(f.evidence.detail)}`
358
+ : '—'
359
+ // U8 — Jev P(true positive); unadjudicated findings render '—'.
360
+ const p = typeof f.p === 'number' ? f.p.toFixed(2) : '—'
361
+ lines.push(
362
+ `| \`${cell(f.file)}\` | ${cell(f.severity)} | ${p} | ${cell(f.category ?? '—')} | ${ev} | ${cell(f.message)} |`,
363
+ )
364
+ }
365
+ if (codeReview.findings.length > MAX_FINDING_ROWS) {
366
+ lines.push(
367
+ `| … | — | — | — | — | ${codeReview.findings.length - MAX_FINDING_ROWS} more findings in \`code-review.json\` |`,
368
+ )
369
+ }
370
+ lines.push('')
371
+ // Secrets-lane audit line — adjudicated/suppressed counts, never literals.
372
+ if (codeReview.secretsScan) {
373
+ if (typeof codeReview.secretsScan.skipped === 'string') {
374
+ lines.push(`*🔐 secrets scan skipped — ${cell(codeReview.secretsScan.skipped)}*`)
375
+ } else if (Array.isArray(codeReview.secretsScan.records)) {
376
+ const suppressed = codeReview.secretsScan.records.filter((r) => r.suppressed).length
377
+ const unadj = codeReview.secretsScan.records.filter((r) => !r.adjudicated).length
378
+ lines.push(
379
+ `*🔐 secrets scan: ${codeReview.secretsScan.records.length} candidate(s)` +
380
+ `${suppressed > 0 ? `, ${suppressed} adjudicated-suppressed` : ''}` +
381
+ `${unadj > 0 ? `, ${unadj} unadjudicated` : ''}` +
382
+ `${codeReview.secretsScan.overflow > 0 ? `, +${codeReview.secretsScan.overflow} over cap` : ''}.*`,
383
+ )
384
+ }
385
+ lines.push('')
386
+ }
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
+ }
402
+ // U8 adjudication audit — outside the findings guard so suppressed-
403
+ // only reviews still show what Jev removed. p values live on the
404
+ // findings table and in code-review.json records.
405
+ if (codeReview.findingAdjudication && Array.isArray(codeReview.findingAdjudication.records)) {
406
+ const fa = codeReview.findingAdjudication
407
+ if (fa.unadjudicated === true) {
408
+ lines.push('*🧮 adjudication: unadjudicated — Jev unavailable, nothing suppressed.*')
409
+ } else {
410
+ const suppressed = fa.records.filter((r) => r.suppressed).length
411
+ const unadj = fa.records.filter((r) => !r.adjudicated).length
412
+ lines.push(
413
+ `*🧮 adjudication: ${fa.records.length} finding(s) scored` +
414
+ `${suppressed > 0 ? `, ${suppressed} suppressed (nit/q)` : ''}` +
415
+ `${unadj > 0 ? `, ${unadj} unadjudicated` : ''}` +
416
+ `${fa.overflow > 0 ? `, +${fa.overflow} over cap` : ''}.*`,
417
+ )
418
+ }
419
+ lines.push('')
420
+ }
421
+ lines.push('</details>')
422
+ lines.push('')
423
+ }
424
+
425
+ // Sticky body for `run: 'false'` consumers — no run.json exists by
426
+ // design, so the body and conclusion reflect code-review alone.
427
+ function renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan) {
428
+ const lines = []
429
+ lines.push(SENTINEL)
430
+ lines.push('')
431
+ lines.push(`## argus-reviewer ${ok ? '✅ PASS' : '❌ FAIL'}`)
432
+ lines.push('')
433
+ pushReviewTop(lines, codeReview)
434
+ if (!codeReview) {
435
+ lines.push(
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.',
437
+ )
438
+ } else if (codeReview.skipped) {
439
+ lines.push(
440
+ `**Summary:** code-review only (run lane disabled) — review skipped: ${cell(codeReview.summary)}`,
441
+ )
442
+ } else {
443
+ lines.push(
444
+ `**Summary:** code review only (run lane disabled) · verdict **${codeReview.verdict}** · ` +
445
+ `${codeReview.findings.length} finding(s) · ${codeReview.model} · ` +
446
+ `${codeReview.tokens}tok ${formatUsd(codeReview.visionCostUsd)}`,
447
+ )
448
+ }
449
+ lines.push('')
450
+ lines.push('<details>')
451
+ lines.push('<summary>🚥 Pre-merge checks</summary>')
452
+ lines.push('')
453
+ lines.push('| Check | Status | Explanation |')
454
+ lines.push('| --- | --- | --- |')
455
+ lines.push('| OpenRouter key | ✅ Passed | `OPENROUTER_API_KEY` configured |')
456
+ if (codeReview && !codeReview.skipped) {
457
+ const codeStatus = codeReview.ok ? '✅ Passed' : '❌ Failed'
458
+ lines.push(
459
+ `| Code review | ${codeStatus} | ${codeReview.findings.length} findings (${codeReview.model}) |`,
460
+ )
461
+ } else {
462
+ lines.push(
463
+ `| Code review | ${codeReview ? '⚪ Skipped' : '❌ Failed'} | ${cell(codeReview?.summary ?? 'no report')} |`,
464
+ )
465
+ }
466
+ lines.push('')
467
+ lines.push('</details>')
468
+ lines.push('')
469
+ pushCodeReviewDetails(lines, codeReview, inlinePlan)
470
+ if (runUrl) lines.push(`[View run](${runUrl})`)
471
+ lines.push('')
472
+ lines.push('<details>')
473
+ lines.push('<summary>✨ Actions</summary>')
474
+ lines.push('')
475
+ lines.push('- [ ] Re-run argus-reviewer')
476
+ lines.push('')
477
+ lines.push('</details>')
478
+ lines.push('')
479
+ lines.push('---')
480
+ lines.push('')
481
+ lines.push('<sub>`argus-reviewer` — self-hosted, BYOK OpenRouter UI regression.</sub>')
482
+ lines.push('')
483
+ return lines.join('\n')
484
+ }
485
+
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 = []
523
+ let page = 1
524
+ for (;;) {
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)
607
+ for (const c of existing) {
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))
614
+ }
615
+ }
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
643
+ }
644
+ }
645
+
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
660
+ }
661
+
662
+ async function postInlineComments(pr, plan) {
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.
671
+ try {
672
+ const reviews = await listAll((p) => github.rest.pulls.listReviews(p), {
673
+ owner,
674
+ repo,
675
+ pull_number: pr.number,
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
+ }
695
+ } catch (e) {
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
+ }
749
+ }
750
+ core.warning(`review post failed: ${lastErr?.message ?? 'unknown error'} — sticky still posts`)
751
+ }
752
+
753
+ async function main() {
754
+ const pr = context.payload && context.payload.pull_request
755
+ const owner = context.repo.owner
756
+ const repo = context.repo.repo
757
+ const hasKey = !!process.env.OPENROUTER_API_KEY
758
+ const workDir = process.env.VISION_E2E_WORKING_DIR || ''
759
+ const reportDir = path.resolve(
760
+ process.env.GITHUB_WORKSPACE,
761
+ workDir,
762
+ process.env.ARGUS_REPORT_DIR || 'argus-reviewer-report',
763
+ )
764
+ const runUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`
765
+
766
+ let report
767
+ let codeReview
768
+ if (hasKey) {
769
+ try {
770
+ const raw = fs.readFileSync(path.join(reportDir, 'run.json'), 'utf8')
771
+ report = JSON.parse(raw)
772
+ } catch {
773
+ report = undefined
774
+ }
775
+ try {
776
+ const raw = fs.readFileSync(path.join(reportDir, 'code-review.json'), 'utf8')
777
+ codeReview = JSON.parse(raw)
778
+ } catch {
779
+ codeReview = undefined
780
+ }
781
+ }
782
+
783
+ // Missing code-review.json after a continue-on-error step means the review
784
+ // crashed, not that it skipped — an intentional skip writes ok+skipped.
785
+ // Fail closed rather than reporting it as a clean skip.
786
+ const codeReviewOk = codeReview != null && codeReview.ok === true
787
+ // run: 'false' consumers have no run.json by design — the conclusion then
788
+ // reflects the code-review verdict alone.
789
+ const runDisabled = process.env.ARGUS_RUN_DISABLED === '1'
790
+ const ok = (runDisabled || report?.ok === true) && codeReviewOk
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.
797
+ const inlinePlan = hasKey ? await planInlineComments(pr, codeReview) : undefined
798
+ if (pr) await postInlineComments(pr, inlinePlan)
799
+ const body = !hasKey
800
+ ? renderMissingKeyBody()
801
+ : runDisabled
802
+ ? renderReviewOnlyBody(codeReview, runUrl, ok, inlinePlan)
803
+ : report === undefined
804
+ ? renderNoReportBody(reportDir, runUrl)
805
+ : renderBody(report, codeReview, runUrl, ok, inlinePlan)
806
+
807
+ if (pr) {
808
+ const { data: comments } = await github.rest.issues.listComments({
809
+ owner,
810
+ repo,
811
+ issue_number: pr.number,
812
+ per_page: 100,
813
+ })
814
+ const existing = comments.find((c) => c.body && c.body.includes(SENTINEL))
815
+ if (existing) {
816
+ await github.rest.issues.updateComment({
817
+ owner,
818
+ repo,
819
+ comment_id: existing.id,
820
+ body,
821
+ })
822
+ } else {
823
+ await github.rest.issues.createComment({
824
+ owner,
825
+ repo,
826
+ issue_number: pr.number,
827
+ body,
828
+ })
829
+ }
830
+ }
831
+
832
+ const sha = pr ? pr.head.sha : context.sha
833
+ // Commit statuses have no 'neutral'; a 'pending' skip would wedge a
834
+ // required check forever, so skip maps to success with a clear label.
835
+ const state = conclusion === 'failure' ? 'failure' : 'success'
836
+ const description =
837
+ conclusion === 'neutral'
838
+ ? 'argus-reviewer skipped (no OPENROUTER_API_KEY)'
839
+ : `argus-reviewer ${conclusion}`
840
+ await github.rest.repos.createCommitStatus({
841
+ owner,
842
+ repo,
843
+ sha,
844
+ state,
845
+ description,
846
+ context: 'argus-reviewer',
847
+ target_url: runUrl,
848
+ })
849
+
850
+ core.setOutput('conclusion', conclusion)
851
+ }
852
+
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
+ }