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.
- package/README.md +86 -49
- package/action/action.yml +155 -61
- 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.cjs +870 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +4 -0
- package/dist/cache/store.d.ts +2 -0
- package/dist/cache/store.js +10 -3
- package/dist/cli.d.ts +137 -0
- package/dist/cli.js +677 -60
- package/dist/config.d.ts +67 -2
- package/dist/config.js +138 -7
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/engine/loop.d.ts +12 -0
- package/dist/engine/loop.js +55 -6
- package/dist/evidence/ci.d.ts +3 -0
- package/dist/evidence/ci.js +3 -1
- 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/probe/queue.d.ts +4 -1
- package/dist/probe/queue.js +46 -8
- 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/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +90 -0
- package/dist/review/secrets.js +224 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +9 -8
- 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
|
+
}
|