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,393 @@
1
+ // Formal pull request review submission.
2
+ //
3
+ // The sticky comment is a PR *comment*; it is not a review object. Branch
4
+ // protection with `require_approving_reviews` reads reviews only, so an
5
+ // Argus verdict alone never satisfies that gate. This module submits a real
6
+ // review via POST /repos/{owner}/{repo}/pulls/{n}/reviews.
7
+ //
8
+ // The token used here must be approval-capable. GitHub refuses approvals from
9
+ // two sources, and both refusals are permanent rather than a missing scope:
10
+ //
11
+ // GITHUB_TOKEN (github-actions[bot])
12
+ // 422 "GitHub Actions is not permitted to approve pull requests."
13
+ // a PAT belonging to the PR author
14
+ // 422 "Review Can not approve your own pull request"
15
+ //
16
+ // So the token has to be a GitHub App installation token, or a PAT from an
17
+ // account that is not the author. See docs/approval-token.md.
18
+
19
+ import { execFile } from 'node:child_process'
20
+ import { readFile } from 'node:fs/promises'
21
+ import { join } from 'node:path'
22
+
23
+ const GH_API = 'https://api.github.com'
24
+
25
+ /** Verdicts that mean "ship it". Everything else blocks. */
26
+ const APPROVING = new Set(['pass', 'approve'])
27
+
28
+ /**
29
+ * Map a code-review verdict onto a GitHub review event.
30
+ *
31
+ * `skipped` resolves to COMMENT, not APPROVE: a run that did not review the
32
+ * diff must never manufacture an approval.
33
+ */
34
+ export function reviewEventFor(verdict) {
35
+ const v = String(verdict ?? '').trim().toLowerCase()
36
+ if (v === 'skipped') return 'COMMENT'
37
+ return APPROVING.has(v) ? 'APPROVE' : 'REQUEST_CHANGES'
38
+ }
39
+
40
+ /**
41
+ * Turn a failed review submission into an actionable message.
42
+ *
43
+ * Both known refusals are GitHub policy, so the remedy is a different
44
+ * identity, never a widened scope. Returns undefined for unknown failures.
45
+ */
46
+ export function classifyApprovalFailure(status, message) {
47
+ const text = String(message ?? '')
48
+ if (text.includes('GitHub Actions is not permitted to approve')) {
49
+ return (
50
+ 'GITHUB_TOKEN cannot approve. Supply the approval-token input with a GitHub App ' +
51
+ 'installation token or a PAT from an account that is not the PR author. ' +
52
+ 'See docs/approval-token.md.'
53
+ )
54
+ }
55
+ if (text.includes('Can not approve your own pull request')) {
56
+ return (
57
+ 'The approval token belongs to the PR author, and GitHub blocks self-approval. ' +
58
+ 'Use an identity that did not open the pull request.'
59
+ )
60
+ }
61
+ if (status === 403) {
62
+ return 'The approval token lacks pull-request write access. Mint an installation token with pull-requests: write.'
63
+ }
64
+ if (status === 404) {
65
+ return 'The approval token cannot see this repository, or the pull request number is wrong.'
66
+ }
67
+ return undefined
68
+ }
69
+
70
+ /**
71
+ * Build the review body: the verdict, the test command the approval is standing
72
+ * on, then the finding count.
73
+ *
74
+ * Deliberately short. The full report, the flow results, and the cost ledger
75
+ * already live in the sticky comment; a formal review is a gate signal, not a
76
+ * second copy of the evidence. The one thing it must not omit is the command,
77
+ * because an approval nobody can re-run is a signature, not a review.
78
+ *
79
+ * @param {{ verdict?: string, summary?: string, findings?: unknown[] }} codeReview
80
+ * @param {string} [runUrl]
81
+ * @param {string} [evidence] test command(s) the approval stands on
82
+ * @param {{ name?: string, conclusion?: string, html_url?: string }} [evidenceCheck]
83
+ * the check run the caller verified before this approval was allowed
84
+ */
85
+ export function reviewBody(codeReview, runUrl, evidence, evidenceCheck) {
86
+ const lines = []
87
+ const verdict = String(codeReview?.verdict ?? 'unknown')
88
+ lines.push(`**Argus verdict:** \`${verdict}\``)
89
+ if (codeReview?.summary) lines.push('', codeReview.summary)
90
+ if (evidence !== undefined && evidence !== '') {
91
+ lines.push('', `**Verified by:** \`${oneLine(evidence)}\``)
92
+ } else {
93
+ lines.push('', '**Verified by:** none — this approval cites no test command.')
94
+ }
95
+ if (evidenceCheck) {
96
+ // The command above is the caller's claim; this line is the receipt. A
97
+ // reviewer can open the run and see the result rather than trust a string.
98
+ const cited = evidenceCheck.html_url
99
+ ? `[\`${oneLine(evidenceCheck.name)}\` → ${evidenceCheck.html_url}](${evidenceCheck.html_url})`
100
+ : `\`${oneLine(evidenceCheck.name)}\``
101
+ lines.push(
102
+ '',
103
+ `**Green on this commit:** ${cited} reported \`${evidenceCheck.conclusion ?? 'unknown'}\`.`,
104
+ )
105
+ }
106
+ const findings = codeReview?.findings
107
+ if (Array.isArray(findings) && findings.length > 0) {
108
+ const blocking = findings.filter((f) => isBlockingSeverity(f?.severity))
109
+ lines.push(
110
+ '',
111
+ `${findings.length} finding(s), ${blocking.length} blocking. ` +
112
+ 'Full evidence and the cost ledger are in the argus-reviewer comment.',
113
+ )
114
+ for (const f of blocking.slice(0, 5)) {
115
+ const where = f.file ? ` \`${f.file}${f.line ? `:${f.line}` : ''}\`` : ''
116
+ lines.push(`- **${f.severity}**${where} — ${oneLine(f.message)}`)
117
+ }
118
+ }
119
+ if (runUrl) lines.push('', `[argus-reviewer run](${runUrl})`)
120
+ lines.push(
121
+ '',
122
+ '<sub>Argus is the reviewer of record. Findings come from `code-review.json` ' +
123
+ 'in the run workspace; this review is generated, not hand-written.</sub>',
124
+ )
125
+ return lines.join('\n')
126
+ }
127
+
128
+ const BLOCKING_SEVERITIES = new Set(['critical', 'high', 'blocker', 'major'])
129
+
130
+ function isBlockingSeverity(severity) {
131
+ return BLOCKING_SEVERITIES.has(String(severity ?? '').trim().toLowerCase())
132
+ }
133
+
134
+ function oneLine(s) {
135
+ return String(s ?? '')
136
+ .replace(/[\r\n]+/g, ' ')
137
+ .trim()
138
+ }
139
+
140
+ /** POST the review. Resolves with the parsed review, throws on API failure. */
141
+ export async function submitApprovalReview({ repo, pr, token, event, body, commitId }) {
142
+ const args = [
143
+ 'api',
144
+ '--method',
145
+ 'POST',
146
+ `${GH_API}/repos/${repo}/pulls/${pr}/reviews`,
147
+ '-f',
148
+ `event=${event}`,
149
+ '-f',
150
+ `body=${body}`,
151
+ ]
152
+ if (commitId) args.push('-f', `commit_id=${commitId}`)
153
+ const { stdout, stderr } = await execFileAsync('gh', args, {
154
+ env: { ...process.env, GH_TOKEN: token },
155
+ maxBuffer: 8 * 1024 * 1024,
156
+ })
157
+ return { stdout, stderr }
158
+ }
159
+
160
+ function execFileAsync(file, args, options) {
161
+ return new Promise((res, rej) => {
162
+ execFile(file, args, options, (error, stdout, stderr) => {
163
+ if (error) {
164
+ error.stdout = stdout
165
+ error.stderr = stderr
166
+ rej(error)
167
+ return
168
+ }
169
+ res({ stdout, stderr })
170
+ })
171
+ })
172
+ }
173
+
174
+ /**
175
+ * List the check runs recorded against a commit.
176
+ *
177
+ * `ref` is the head SHA, not the branch tip: an approval has to describe the
178
+ * code it was actually run against, so it is bound to the same SHA the review
179
+ * is submitted with.
180
+ */
181
+ export async function listCheckRuns({ repo, ref, token }) {
182
+ const args = [
183
+ 'api',
184
+ '--method',
185
+ 'GET',
186
+ `${GH_API}/repos/${repo}/commits/${ref}/check-runs?per_page=100`,
187
+ ]
188
+ const { stdout, stderr } = await execFileAsync('gh', args, {
189
+ env: { ...process.env, GH_TOKEN: token },
190
+ maxBuffer: 8 * 1024 * 1024,
191
+ })
192
+ const parsed = JSON.parse(stdout)
193
+ return Array.isArray(parsed?.check_runs) ? parsed.check_runs : []
194
+ }
195
+
196
+ function isGreenCheck(check) {
197
+ return (
198
+ String(check?.status ?? '').toLowerCase() === 'completed' &&
199
+ String(check?.conclusion ?? '').toLowerCase() === 'success'
200
+ )
201
+ }
202
+
203
+ function norm(s) {
204
+ return String(s ?? '')
205
+ .trim()
206
+ .toLowerCase()
207
+ }
208
+
209
+ /**
210
+ * Decide whether a named check run can carry an approval.
211
+ *
212
+ * A cited test command is a claim. This is the part that makes it evidence:
213
+ * the named check must exist on the head commit and have finished green. It
214
+ * returns the check on success so the review body can cite the real run rather
215
+ * than repeat the caller's string back at them.
216
+ *
217
+ * Two properties matter as much as the name match:
218
+ *
219
+ * - **The producer is pinned.** A check *name* is not a producer. A fork can
220
+ * add a workflow that reports a green run called `test`, so a gate that
221
+ * accepts any green `test` is satisfied by whoever was under review. `appSlug`
222
+ * restricts the match to one App, and an empty value means "any producer",
223
+ * which the action never passes by default.
224
+ * - **Every match must be green.** One green run does not speak for a failing
225
+ * one with the same name, and picking the greenest match is exactly how a
226
+ * red build gets waved through.
227
+ *
228
+ * @param {Array<{name?: string, status?: string, conclusion?: string, app?: {slug?: string}}>} checkRuns
229
+ * @param {string} name
230
+ * @param {string} [appSlug] required producer; empty matches any
231
+ * @returns {{ ok: boolean, reason?: string, check?: object }}
232
+ */
233
+ export function verifyEvidenceCheck(checkRuns, name, appSlug) {
234
+ const wanted = norm(name)
235
+ if (wanted === '') return { ok: false, reason: 'no-check-named' }
236
+ // '' and '*' both mean "any producer". The action and the lane never pass
237
+ // either by default; reaching this needs an explicit opt-out.
238
+ const producer = norm(appSlug)
239
+ const anyProducer = producer === '' || producer === '*'
240
+ const all = Array.isArray(checkRuns) ? checkRuns : []
241
+ const byName = all.filter((r) => norm(r?.name) === wanted)
242
+ if (byName.length === 0) return { ok: false, reason: 'no-check-named' }
243
+ const matches = anyProducer ? byName : byName.filter((r) => norm(r?.app?.slug) === producer)
244
+ if (matches.length === 0) return { ok: false, reason: 'wrong-producer' }
245
+ if (matches.every(isGreenCheck)) return { ok: true, check: matches[0] }
246
+ // Report a still-running state ahead of a failed one: "pending" is transient,
247
+ // "failed" is the outcome somebody has to act on.
248
+ const running = matches.find((r) => norm(r?.status) !== 'completed')
249
+ const red = matches.find((r) => !isGreenCheck(r))
250
+ return { ok: false, reason: running ? 'not-completed' : 'not-successful', check: running ?? red }
251
+ }
252
+
253
+ /**
254
+ * Explain a refused approval in terms of the check, not the token. Returns the
255
+ * whole sentence so the caller cannot accidentally emit a vaguer one.
256
+ */
257
+ export function classifyEvidenceFailure(name, verdict, appSlug) {
258
+ const label = `\`${name}\``
259
+ const seen = verdict?.check
260
+ const suffix = seen?.html_url ? ` (${seen.html_url})` : ''
261
+ if (verdict?.reason === 'wrong-producer') {
262
+ const from = appSlug ? `\`${appSlug}\`` : 'the expected App'
263
+ return (
264
+ `A check run named ${label} exists on the head commit${suffix}, but it did not come from ` +
265
+ `${from}. Argus only accepts that producer's result, because a check name is not a producer. ` +
266
+ 'Set approval-check-app to the App slug that runs this check.'
267
+ )
268
+ }
269
+ if (verdict?.reason === 'not-completed') {
270
+ return (
271
+ `${label} has not finished on the head commit${suffix}, so there is no evidence the cited ` +
272
+ 'tests ran. Refusing to approve.'
273
+ )
274
+ }
275
+ if (verdict?.reason === 'not-successful') {
276
+ return (
277
+ `${label} reported \`${seen?.conclusion ?? 'no conclusion'}\` on the head commit${suffix}. ` +
278
+ 'Refusing to approve a commit whose named check is not green.'
279
+ )
280
+ }
281
+ return (
282
+ `No check run named ${label} is recorded on the head commit, so the test command this ` +
283
+ 'approval cites cannot be shown to have run. Set approval-check to a required check on this ' +
284
+ 'repository, or drop approval-token to stay on the comment lane.'
285
+ )
286
+ }
287
+
288
+ // Stale approval.
289
+ //
290
+ // `dismiss_stale_reviews` is false on the protected branch, so an APPROVE
291
+ // survives every later push to the same pull request. That is not theoretical:
292
+ // on duketopceo/orchestral, 5 of 9 cursor approvals were issued at one head SHA
293
+ // and the pull request then merged at a different one — up to 8 commits landed
294
+ // after the approval. A reviewer cannot revoke their own stale approval unless
295
+ // something does it for them, so this lane does.
296
+ //
297
+ // The two operations below close different windows and neither closes all of
298
+ // it. The gap between a push and this lane starting is closed only by enabling
299
+ // `dismiss_stale_reviews`, which is a branch-protection setting and not ours
300
+ // to change.
301
+
302
+ /** Read the current head SHA of a pull request. */
303
+ export async function readPullRequestHead({ repo, pr, token }) {
304
+ const args = ['api', '--method', 'GET', `${GH_API}/repos/${repo}/pulls/${pr}`]
305
+ const { stdout, stderr } = await execFileAsync('gh', args, {
306
+ env: { ...process.env, GH_TOKEN: token },
307
+ maxBuffer: 8 * 1024 * 1024,
308
+ })
309
+ return String(JSON.parse(stdout)?.head?.sha ?? '')
310
+ }
311
+
312
+ /** List the reviews already on a pull request. */
313
+ export async function listReviews({ repo, pr, token }) {
314
+ const args = [
315
+ 'api',
316
+ '--method',
317
+ 'GET',
318
+ `${GH_API}/repos/${repo}/pulls/${pr}/reviews?per_page=100`,
319
+ ]
320
+ const { stdout, stderr } = await execFileAsync('gh', args, {
321
+ env: { ...process.env, GH_TOKEN: token },
322
+ maxBuffer: 8 * 1024 * 1028,
323
+ })
324
+ const parsed = JSON.parse(stdout)
325
+ return Array.isArray(parsed) ? parsed : []
326
+ }
327
+
328
+ /** Dismiss one review, so it stops counting toward the gate. */
329
+ export async function dismissReview({ repo, pr, reviewId, token, message }) {
330
+ const args = [
331
+ 'api',
332
+ '--method',
333
+ 'PUT',
334
+ `${GH_API}/repos/${repo}/pulls/${pr}/reviews/${reviewId}/dismissals`,
335
+ '-f',
336
+ `message=${message}`,
337
+ ]
338
+ const { stdout, stderr } = await execFileAsync('gh', args, {
339
+ env: { ...process.env, GH_TOKEN: token },
340
+ maxBuffer: 8 * 1024 * 1024,
341
+ })
342
+ return JSON.parse(stdout)
343
+ }
344
+
345
+ /**
346
+ * Prior approvals by this same identity that no longer describe the head.
347
+ *
348
+ * Scoped to `login` on purpose. An approver revoking *its own* outgrown
349
+ * approval is self-correction; an approver revoking a colleague's is not
350
+ * something a bot should do, so anyone else's review is left alone.
351
+ *
352
+ * @param {Array<{state?: string, commit_id?: string, user?: {login?: string}, id?: number}>} reviews
353
+ * @param {{ login?: string, headSha?: string }} opts
354
+ */
355
+ export function staleApprovals(reviews, { login, headSha }) {
356
+ const want = String(login ?? '')
357
+ .trim()
358
+ .toLowerCase()
359
+ if (want === '') return []
360
+ const head = String(headSha ?? '')
361
+ return (Array.isArray(reviews) ? reviews : []).filter(
362
+ (r) =>
363
+ String(r?.state ?? '').toUpperCase() === 'APPROVED' &&
364
+ String(r?.user?.login ?? '')
365
+ .trim()
366
+ .toLowerCase() === want &&
367
+ String(r?.commit_id ?? '') !== head,
368
+ )
369
+ }
370
+
371
+ /** Read the code-review report the harness already wrote. */
372
+ export async function readCodeReview(reportDir) { const raw = await readFile(join(reportDir, 'code-review.json'), 'utf8')
373
+ return JSON.parse(raw)
374
+ }
375
+
376
+ /**
377
+ * Resolve the pull request number from the event payload.
378
+ *
379
+ * `pull_request` events put it at `.pull_request.number`; `pull_request_target`
380
+ * puts it at `.number`; `issue_comment` events carry it at `.issue.number`.
381
+ * Precedence matters — a `pull_request` payload also has a top-level `number`,
382
+ * and for a PR-triggered event they agree, but relying on that is fragile.
383
+ */
384
+ export function prNumberFromEvent(eventName, payload) {
385
+ const candidates = []
386
+ if (eventName === 'issue_comment') candidates.push(payload?.issue?.number)
387
+ candidates.push(payload?.pull_request?.number, payload?.number)
388
+ for (const c of candidates) {
389
+ const n = Number(c)
390
+ if (Number.isInteger(n) && n > 0) return n
391
+ }
392
+ return undefined
393
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { copyFile, mkdtemp } from 'node:fs/promises'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ import {
9
+ assertRegularFile,
10
+ assertRegularFileInside,
11
+ parseCommand,
12
+ resolveWorkingDirectory,
13
+ setActionOutput,
14
+ validateBrowser,
15
+ validateBudget,
16
+ validateMaxComments,
17
+ validatePathInput,
18
+ validateVersion,
19
+ } from './runtime.mjs'
20
+
21
+ const actionDir = dirname(fileURLToPath(import.meta.url))
22
+ const workspace = resolve(process.env.GITHUB_WORKSPACE || process.cwd())
23
+ const cwd = resolveWorkingDirectory(workspace, process.env.ARGUS_WORKING_DIRECTORY)
24
+ const configInput = validatePathInput(process.env.ARGUS_CONFIG_PATH, 'config')
25
+ const reportDir = validatePathInput(
26
+ process.env.ARGUS_REPORT_DIR || 'argus-reviewer-report',
27
+ 'report-dir',
28
+ )
29
+ validateBrowser(process.env.ARGUS_BROWSER || 'chromium')
30
+ validateBudget(process.env.ARGUS_BUDGET_USD)
31
+ validateMaxComments(process.env.ARGUS_MAX_COMMENTS)
32
+
33
+ async function stageConfig() {
34
+ if (configInput === '' || configInput === 'argus-reviewer.config.ts') return
35
+ const ext = configInput.slice(configInput.lastIndexOf('.')).toLowerCase()
36
+ if (ext !== '.ts' && ext !== '.json') {
37
+ throw new Error(`config must use .ts or .json, got: ${configInput}`)
38
+ }
39
+ const source = await assertRegularFile(
40
+ assertRegularFileInside(cwd, configInput, 'config'),
41
+ 'config',
42
+ )
43
+ if (source === join(cwd, `argus-reviewer.config${ext}`)) return
44
+ await copyFile(source, join(cwd, `argus-reviewer.config${ext}`))
45
+ }
46
+
47
+ await stageConfig()
48
+
49
+ let cli
50
+ const override = process.env.ARGUS_CLI?.trim() ?? ''
51
+ if (override !== '') {
52
+ cli = parseCommand(override)
53
+ } else {
54
+ const version = process.env.ARGUS_ARGUS_VERSION?.trim() ?? ''
55
+ if (version === '') {
56
+ const runtime = await mkdtemp(join(tmpdir(), 'argus-reviewer-action-pinned-'))
57
+ const result = spawnSync(
58
+ 'npm',
59
+ [
60
+ 'install',
61
+ '--prefix',
62
+ runtime,
63
+ '--ignore-scripts',
64
+ '--no-audit',
65
+ '--no-fund',
66
+ '--no-package-lock',
67
+ '--no-save',
68
+ resolve(actionDir, '..'),
69
+ ],
70
+ { stdio: 'inherit', shell: false },
71
+ )
72
+ if (result.status !== 0) throw new Error('could not install the CLI from the pinned action ref')
73
+ cli = [process.execPath, join(runtime, 'node_modules', 'argus-reviewer-e2e', 'dist', 'cli.js')]
74
+ } else {
75
+ validateVersion(version)
76
+ const runtime = await mkdtemp(join(tmpdir(), `argus-reviewer-action-${version}-`))
77
+ const result = spawnSync(
78
+ 'npm',
79
+ [
80
+ 'install',
81
+ '--prefix',
82
+ runtime,
83
+ '--ignore-scripts',
84
+ '--no-audit',
85
+ '--no-fund',
86
+ '--no-package-lock',
87
+ '--no-save',
88
+ `argus-reviewer-e2e@${version}`,
89
+ ],
90
+ { stdio: 'inherit', shell: false },
91
+ )
92
+ if (result.status !== 0) {
93
+ throw new Error(`could not bootstrap pinned argus-reviewer-e2e@${version}`)
94
+ }
95
+ cli = [process.execPath, join(runtime, 'node_modules', 'argus-reviewer-e2e', 'dist', 'cli.js')]
96
+ }
97
+ }
98
+
99
+ setActionOutput('cli-json', JSON.stringify(cli))
100
+ setActionOutput('working-directory', cwd)
101
+ setActionOutput('report-dir', reportDir)
102
+ console.log(`argus action: prepared ${cli[0]} in ${cwd}`)
package/action/cli.mjs ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+
4
+ function fail(message, code = 2) {
5
+ console.error(`argus action: ${message}`)
6
+ process.exitCode = code
7
+ }
8
+
9
+ let spec
10
+ try {
11
+ spec = JSON.parse(process.env.ARGUS_CLI_JSON ?? '')
12
+ } catch (error) {
13
+ fail(`invalid CLI bootstrap output: ${error.message}`)
14
+ process.exit()
15
+ }
16
+
17
+ if (!Array.isArray(spec) || spec.length === 0 || typeof spec[0] !== 'string') {
18
+ fail('CLI bootstrap output is not an argv array')
19
+ process.exit()
20
+ }
21
+
22
+ const child = spawn(spec[0], [...spec.slice(1), ...process.argv.slice(2)], {
23
+ cwd: process.env.ARGUS_WORKING_DIRECTORY || process.cwd(),
24
+ env: process.env,
25
+ shell: false,
26
+ stdio: 'inherit',
27
+ })
28
+
29
+ child.on('error', (error) => fail(error.message, 127))
30
+ child.on('close', (code, signal) => {
31
+ if (signal) {
32
+ process.exitCode = 1
33
+ return
34
+ }
35
+ process.exitCode = code ?? 1
36
+ })