argus-reviewer-e2e 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -64
- package/action/action.yml +149 -66
- package/action/approval-review.mjs +393 -0
- package/action/bootstrap.mjs +104 -0
- package/action/cli.mjs +36 -0
- package/action/emit-review.mjs +336 -0
- package/action/runtime.mjs +166 -0
- package/action/{sticky-comment.mjs → sticky-comment.cjs} +360 -74
- package/dist/api.d.ts +1 -0
- package/dist/api.js +3 -0
- package/dist/cache/store.d.ts +2 -0
- package/dist/cache/store.js +10 -3
- package/dist/cli.d.ts +80 -10
- package/dist/cli.js +302 -13
- package/dist/config.d.ts +4 -0
- package/dist/config.js +32 -4
- package/dist/engine/loop.d.ts +12 -0
- package/dist/engine/loop.js +55 -6
- package/dist/pipeline/budget.d.ts +11 -0
- package/dist/pipeline/budget.js +48 -0
- package/dist/pipeline/contracts.d.ts +12 -0
- package/dist/pipeline/contracts.js +16 -0
- package/dist/pipeline/verify.d.ts +27 -0
- package/dist/pipeline/verify.js +197 -0
- package/dist/report/manifest.d.ts +87 -0
- package/dist/report/manifest.js +143 -0
- package/dist/report/run.d.ts +9 -0
- package/dist/report/run.js +6 -0
- package/dist/review/secrets.d.ts +2 -0
- package/dist/review/secrets.js +4 -0
- package/package.json +7 -7
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// CLI entrypoint for the approval-review lane.
|
|
2
|
+
//
|
|
3
|
+
// Reads the harness's own code-review.json, maps its verdict to a GitHub
|
|
4
|
+
// review event, and submits the review with the approval-capable token. Runs
|
|
5
|
+
// as its own composite step because actions/github-script's Octokit is pinned
|
|
6
|
+
// to `github.token`, which GitHub refuses to approve with.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
classifyApprovalFailure,
|
|
12
|
+
classifyEvidenceFailure,
|
|
13
|
+
dismissReview,
|
|
14
|
+
listCheckRuns,
|
|
15
|
+
listReviews,
|
|
16
|
+
prNumberFromEvent,
|
|
17
|
+
readCodeReview,
|
|
18
|
+
readPullRequestHead,
|
|
19
|
+
reviewBody,
|
|
20
|
+
reviewEventFor,
|
|
21
|
+
staleApprovals,
|
|
22
|
+
submitApprovalReview,
|
|
23
|
+
verifyEvidenceCheck,
|
|
24
|
+
} from './approval-review.mjs'
|
|
25
|
+
import { setActionOutput } from './runtime.mjs'
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CHECK_APP = 'github-actions'
|
|
28
|
+
|
|
29
|
+
function warn(message) {
|
|
30
|
+
process.stdout.write(`::warning::${message}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fail(message) {
|
|
34
|
+
process.stdout.write(`::error::${message}\n`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readEventPayload(path) {
|
|
38
|
+
if (!path) return undefined
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statusFromMessage(message) {
|
|
47
|
+
const m = /HTTP (\d{3})/.exec(String(message))
|
|
48
|
+
return m ? Number(m[1]) : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function detailFrom(err) {
|
|
52
|
+
const raw = String(err?.stderr ?? err?.message ?? '')
|
|
53
|
+
return (
|
|
54
|
+
raw
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map((l) => l.trim())
|
|
57
|
+
.filter((l) => l !== '' && !l.startsWith('{'))
|
|
58
|
+
.slice(-1)[0] ?? 'no detail'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortSha(sha) {
|
|
63
|
+
const s = String(sha ?? '')
|
|
64
|
+
return s.length > 7 ? s.slice(0, 7) : s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Dismiss this identity's own approvals that describe a commit other than the
|
|
69
|
+
* current head.
|
|
70
|
+
*
|
|
71
|
+
* Best effort by design. The new approval is already submitted, so failing the
|
|
72
|
+
* step here would report a successful review as broken. A dismissal that fails
|
|
73
|
+
* is a real gap in the control, so it is logged loudly and surfaced as an
|
|
74
|
+
* output rather than swallowed.
|
|
75
|
+
*/
|
|
76
|
+
async function retireStaleApprovals({ repo, pr, token, headSha, login, readReviews, dismiss, log }) {
|
|
77
|
+
const warn = (m) => process.stdout.write(`::warning::${m}\n`)
|
|
78
|
+
let existing
|
|
79
|
+
try {
|
|
80
|
+
existing = await readReviews({ repo, pr, token })
|
|
81
|
+
} catch (err) {
|
|
82
|
+
warn(
|
|
83
|
+
`argus-reviewer: could not list existing reviews on #${pr} — ${err?.message ?? err}. ` +
|
|
84
|
+
'Any earlier approval of an older commit by this identity is still standing.',
|
|
85
|
+
)
|
|
86
|
+
setActionOutput('stale-dismissed', 'unknown')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const stale = staleApprovals(existing, { login, headSha })
|
|
90
|
+
if (stale.length === 0) {
|
|
91
|
+
setActionOutput('stale-dismissed', '0')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
let dismissed = 0
|
|
95
|
+
for (const r of stale) {
|
|
96
|
+
try {
|
|
97
|
+
await dismiss({
|
|
98
|
+
repo,
|
|
99
|
+
pr,
|
|
100
|
+
reviewId: r.id,
|
|
101
|
+
token,
|
|
102
|
+
message: `Superseded: this approval covered ${shortSha(r.commit_id)}, which is no longer the head of this pull request.`,
|
|
103
|
+
})
|
|
104
|
+
dismissed += 1
|
|
105
|
+
} catch (err) {
|
|
106
|
+
warn(
|
|
107
|
+
`argus-reviewer: could not dismiss review ${r.id} on #${pr} — ${err?.message ?? err}. ` +
|
|
108
|
+
'It still counts toward the approval gate for a commit it did not review.',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
log(
|
|
113
|
+
`argus-reviewer: retired ${dismissed} of ${stale.length} stale approval(s) by this identity on #${pr} ` +
|
|
114
|
+
`(head ${shortSha(headSha)}).`,
|
|
115
|
+
)
|
|
116
|
+
setActionOutput('stale-dismissed', String(dismissed))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve everything the lane needs from the step environment.
|
|
121
|
+
* Exported so the wiring is testable without a runner.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveLane(env) {
|
|
124
|
+
const token = (env.ARGUS_APPROVAL_TOKEN ?? '').trim()
|
|
125
|
+
const repo = (env.GITHUB_REPOSITORY ?? '').trim()
|
|
126
|
+
const reportDir = (env.ARGUS_REPORT_DIR ?? 'argus-reviewer-report').trim()
|
|
127
|
+
const workDir = (env.VISION_E2E_WORKING_DIR ?? '').trim()
|
|
128
|
+
const pr = Number(env.ARGUS_PR_NUMBER ?? '') || prNumberFromEvent(env.GITHUB_EVENT_NAME, env.__event)
|
|
129
|
+
return {
|
|
130
|
+
token,
|
|
131
|
+
evidence: (env.ARGUS_APPROVAL_EVIDENCE ?? '').trim(),
|
|
132
|
+
check: (env.ARGUS_APPROVAL_CHECK ?? '').trim(),
|
|
133
|
+
// Defaults here as well as on the action input, so a workflow that sets the
|
|
134
|
+
// env directly still gets the pinned producer. An empty value would mean
|
|
135
|
+
// "any producer", which is exactly what the fork-forgery case exploits.
|
|
136
|
+
checkApp: (env.ARGUS_APPROVAL_CHECK_APP ?? '').trim() || DEFAULT_CHECK_APP,
|
|
137
|
+
repo,
|
|
138
|
+
pr,
|
|
139
|
+
reportDir: workDir === '' ? reportDir : `${workDir}/${reportDir}`,
|
|
140
|
+
headSha: (env.ARGUS_HEAD_SHA ?? '').trim() || undefined,
|
|
141
|
+
runUrl: env.GITHUB_RUN_ID
|
|
142
|
+
? `${env.GITHUB_SERVER_URL ?? 'https://github.com'}/${repo}/actions/runs/${env.GITHUB_RUN_ID}`
|
|
143
|
+
: undefined,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run the lane and report what happened. Returns rather than setting
|
|
149
|
+
* `process.exitCode` so the outcome is assertable in tests; the entrypoint
|
|
150
|
+
* below is what turns a non-ok result into a failed step.
|
|
151
|
+
*
|
|
152
|
+
* @param {Record<string, string | undefined>} env
|
|
153
|
+
* @param {{ submit?: typeof submitApprovalReview, read?: typeof readCodeReview, list?: typeof listCheckRuns, head?: typeof readPullRequestHead, reviews?: typeof listReviews, dismiss?: typeof dismissReview }} [deps]
|
|
154
|
+
* @returns {Promise<{ ok: boolean, reviewEvent: string, reviewState: string, message: string }>}
|
|
155
|
+
*/
|
|
156
|
+
export async function emitApprovalReview(env, deps = {}) {
|
|
157
|
+
const submit = deps.submit ?? submitApprovalReview
|
|
158
|
+
const read = deps.read ?? readCodeReview
|
|
159
|
+
const list = deps.list ?? listCheckRuns
|
|
160
|
+
const readHead = deps.head ?? readPullRequestHead
|
|
161
|
+
const readReviews = deps.reviews ?? listReviews
|
|
162
|
+
const dismiss = deps.dismiss ?? dismissReview
|
|
163
|
+
const log = (line) => process.stdout.write(`${line}\n`)
|
|
164
|
+
const { token, evidence, check, checkApp, repo, pr, reportDir, headSha, runUrl } = resolveLane(env)
|
|
165
|
+
|
|
166
|
+
if (token === '') {
|
|
167
|
+
// Not an error. The sticky comment is Argus's default behaviour; approving
|
|
168
|
+
// is opt-in and needs a credential this step was not handed.
|
|
169
|
+
const message = 'argus-reviewer: no approval-token supplied — comment only, no formal review.'
|
|
170
|
+
log(message)
|
|
171
|
+
setActionOutput('review-event', 'none')
|
|
172
|
+
setActionOutput('review-state', 'not-submitted')
|
|
173
|
+
return { ok: true, reviewEvent: 'none', reviewState: 'not-submitted', message }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (evidence === '') {
|
|
177
|
+
// Enforced, not advisory. An approval on a protected branch that cites no
|
|
178
|
+
// command is a rubber stamp with extra steps: nobody can re-run what the
|
|
179
|
+
// approval is standing on. Refuse rather than emit an uncited APPROVE.
|
|
180
|
+
const message =
|
|
181
|
+
'argus-reviewer: approval-token was supplied without approval-evidence. An approval must ' +
|
|
182
|
+
'cite the test command it stands on. Set approval-evidence to the command(s) that verify ' +
|
|
183
|
+
'this pull request, or drop approval-token to stay on the comment lane.'
|
|
184
|
+
fail(message)
|
|
185
|
+
setActionOutput('review-event', 'none')
|
|
186
|
+
setActionOutput('review-state', 'no-evidence')
|
|
187
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'no-evidence', message }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (repo === '') {
|
|
191
|
+
const message = 'argus-reviewer: GITHUB_REPOSITORY is empty; cannot submit a review.'
|
|
192
|
+
fail(message)
|
|
193
|
+
setActionOutput('review-event', 'none')
|
|
194
|
+
setActionOutput('review-state', 'no-repo')
|
|
195
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'no-repo', message }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!pr) {
|
|
199
|
+
// workflow_dispatch and push carry no pull request. Not a failure.
|
|
200
|
+
const message = 'argus-reviewer: no pull request in this event — skipping the review lane.'
|
|
201
|
+
log(message)
|
|
202
|
+
setActionOutput('review-event', 'none')
|
|
203
|
+
setActionOutput('review-state', 'no-pull-request')
|
|
204
|
+
return { ok: true, reviewEvent: 'none', reviewState: 'no-pull-request', message }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let codeReview
|
|
208
|
+
try {
|
|
209
|
+
codeReview = await read(reportDir)
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// The code-review step failed or never ran. Fail closed and loudly: a
|
|
212
|
+
// missing report must never become an approval, and must never be silent.
|
|
213
|
+
const message =
|
|
214
|
+
`argus-reviewer: cannot read ${reportDir}/code-review.json — ${err?.message ?? err}. ` +
|
|
215
|
+
'No review was submitted.'
|
|
216
|
+
fail(message)
|
|
217
|
+
setActionOutput('review-event', 'none')
|
|
218
|
+
setActionOutput('review-state', 'no-report')
|
|
219
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'no-report', message }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const event = reviewEventFor(codeReview.verdict)
|
|
223
|
+
|
|
224
|
+
// The run reviewed the code at the head SHA its event carried. If the head has
|
|
225
|
+
// moved since, this review describes commits nobody looked at, and GitHub
|
|
226
|
+
// would attach it to a commit that is no longer the head. Refuse and let a
|
|
227
|
+
// fresh run answer the new head — the same reason `dismiss_stale_reviews`
|
|
228
|
+
// exists, enforced at submit time instead of in the branch settings.
|
|
229
|
+
if (headSha !== undefined) {
|
|
230
|
+
let currentHead
|
|
231
|
+
try {
|
|
232
|
+
currentHead = await readHead({ repo, pr, token })
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const message =
|
|
235
|
+
`argus-reviewer: could not read the current head of #${pr} — ${err?.message ?? err}. ` +
|
|
236
|
+
'No review was submitted.'
|
|
237
|
+
fail(message)
|
|
238
|
+
setActionOutput('review-event', 'none')
|
|
239
|
+
setActionOutput('review-state', 'no-head')
|
|
240
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'no-head', message }
|
|
241
|
+
}
|
|
242
|
+
if (currentHead !== '' && currentHead !== headSha) {
|
|
243
|
+
const message =
|
|
244
|
+
`argus-reviewer: the head of #${pr} moved from ${shortSha(headSha)} to ${shortSha(currentHead)} ` +
|
|
245
|
+
'while this run was working. This run reviewed the older commit, so no review was submitted. ' +
|
|
246
|
+
'The push triggers a new run, which will review the new head.'
|
|
247
|
+
fail(message)
|
|
248
|
+
setActionOutput('review-event', 'none')
|
|
249
|
+
setActionOutput('review-state', 'head-moved')
|
|
250
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'head-moved', message }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// APPROVE is the only event that satisfies a protected branch, so it is the
|
|
255
|
+
// only one that has to be backed by a check which actually ran. A negative
|
|
256
|
+
// review is exempt on purpose: red CI is exactly when a REQUEST_CHANGES
|
|
257
|
+
// should still go out.
|
|
258
|
+
let evidenceCheck
|
|
259
|
+
if (event === 'APPROVE') {
|
|
260
|
+
// Two ways to have no evidence, both cheap to detect without an API call:
|
|
261
|
+
// nobody named a check, or the head commit is unknown so the check cannot
|
|
262
|
+
// be bound to anything. Binding to a branch tip instead is the hole this
|
|
263
|
+
// exists to close, so an unknown SHA is a refusal rather than a guess.
|
|
264
|
+
if (check === '' || headSha === undefined) {
|
|
265
|
+
const message = `argus-reviewer: ${classifyEvidenceFailure(check, { ok: false, reason: 'no-check-named' }, checkApp)}`
|
|
266
|
+
fail(message)
|
|
267
|
+
setActionOutput('review-event', 'none')
|
|
268
|
+
setActionOutput('review-state', 'unverified-evidence')
|
|
269
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'unverified-evidence', message }
|
|
270
|
+
}
|
|
271
|
+
let verdictOnCheck
|
|
272
|
+
try {
|
|
273
|
+
verdictOnCheck = verifyEvidenceCheck(await list({ repo, ref: headSha, token }), check, checkApp)
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const message =
|
|
276
|
+
`argus-reviewer: could not read check runs for ${headSha} — ${err?.message ?? err}. ` +
|
|
277
|
+
'No review was submitted.'
|
|
278
|
+
fail(message)
|
|
279
|
+
setActionOutput('review-event', 'none')
|
|
280
|
+
setActionOutput('review-state', 'no-check-runs')
|
|
281
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'no-check-runs', message }
|
|
282
|
+
}
|
|
283
|
+
if (!verdictOnCheck.ok) {
|
|
284
|
+
const message = `argus-reviewer: ${classifyEvidenceFailure(check, verdictOnCheck, checkApp)}`
|
|
285
|
+
fail(message)
|
|
286
|
+
setActionOutput('review-event', 'none')
|
|
287
|
+
setActionOutput('review-state', 'unverified-evidence')
|
|
288
|
+
return { ok: false, reviewEvent: 'none', reviewState: 'unverified-evidence', message }
|
|
289
|
+
}
|
|
290
|
+
evidenceCheck = verdictOnCheck.check
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const { stdout } = await submit({
|
|
295
|
+
repo,
|
|
296
|
+
pr,
|
|
297
|
+
token,
|
|
298
|
+
event,
|
|
299
|
+
body: reviewBody(codeReview, runUrl, evidence, evidenceCheck),
|
|
300
|
+
commitId: headSha,
|
|
301
|
+
})
|
|
302
|
+
const review = JSON.parse(stdout)
|
|
303
|
+
const message =
|
|
304
|
+
`argus-reviewer: submitted ${event} on #${pr} as ${review.user?.login ?? 'unknown'} ` +
|
|
305
|
+
`(review ${review.id}, state ${review.state}).`
|
|
306
|
+
log(message)
|
|
307
|
+
setActionOutput('review-event', event)
|
|
308
|
+
setActionOutput('review-state', review.state ?? 'unknown')
|
|
309
|
+
|
|
310
|
+
// Retire this identity's own approvals of commits that are no longer the
|
|
311
|
+
// head. Done *after* the submit on purpose: if the submit fails, the
|
|
312
|
+
// previous approval is still standing, and a failed review must never be
|
|
313
|
+
// the reason a pull request loses the approval it already had.
|
|
314
|
+
if (event === 'APPROVE' && headSha !== undefined) {
|
|
315
|
+
await retireStaleApprovals({ repo, pr, token, headSha, login: review.user?.login, readReviews, dismiss, log })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { ok: true, reviewEvent: event, reviewState: review.state ?? 'unknown', message }
|
|
319
|
+
} catch (err) {
|
|
320
|
+
const status = err?.status ?? statusFromMessage(err?.message ?? '')
|
|
321
|
+
const remedy = classifyApprovalFailure(status, `${err?.message ?? ''} ${err?.stderr ?? ''}`)
|
|
322
|
+
const message =
|
|
323
|
+
`argus-reviewer: could not submit the ${event} review on #${pr}. ` +
|
|
324
|
+
(remedy ?? `GitHub returned ${status ?? 'an error'}: ${detailFrom(err)}`)
|
|
325
|
+
fail(message)
|
|
326
|
+
setActionOutput('review-event', event)
|
|
327
|
+
setActionOutput('review-state', 'failed')
|
|
328
|
+
return { ok: false, reviewEvent: event, reviewState: 'failed', message }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (process.argv[1] && process.argv[1].endsWith('emit-review.mjs')) {
|
|
333
|
+
const event = readEventPayload(process.env.GITHUB_EVENT_PATH)
|
|
334
|
+
const result = await emitApprovalReview({ ...process.env, __event: event })
|
|
335
|
+
if (!result.ok) process.exitCode = 1
|
|
336
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises'
|
|
2
|
+
import { appendFileSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const SHELL_META = /[;&|<>`$\\()[\]{}\r\n\0]/
|
|
6
|
+
|
|
7
|
+
/** Parse the legacy `cli` input as an argv array without invoking a shell. */
|
|
8
|
+
export function parseCommand(raw) {
|
|
9
|
+
const input = String(raw ?? '').trim()
|
|
10
|
+
if (input === '') return undefined
|
|
11
|
+
if (SHELL_META.test(input)) {
|
|
12
|
+
throw new Error('cli must be an executable plus arguments; shell operators are not allowed')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tokens = []
|
|
16
|
+
let token = ''
|
|
17
|
+
let quote
|
|
18
|
+
let escaped = false
|
|
19
|
+
let tokenStarted = false
|
|
20
|
+
|
|
21
|
+
for (const char of input) {
|
|
22
|
+
if (escaped) {
|
|
23
|
+
token += char
|
|
24
|
+
escaped = false
|
|
25
|
+
tokenStarted = true
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (char === '\\' && quote !== "'") {
|
|
29
|
+
escaped = true
|
|
30
|
+
tokenStarted = true
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (quote !== undefined) {
|
|
34
|
+
if (char === quote) quote = undefined
|
|
35
|
+
else token += char
|
|
36
|
+
tokenStarted = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (char === '"' || char === "'") {
|
|
40
|
+
quote = char
|
|
41
|
+
tokenStarted = true
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (/\s/.test(char)) {
|
|
45
|
+
if (tokenStarted) {
|
|
46
|
+
tokens.push(token)
|
|
47
|
+
token = ''
|
|
48
|
+
tokenStarted = false
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
token += char
|
|
53
|
+
tokenStarted = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (escaped) throw new Error('cli ends with an incomplete escape')
|
|
57
|
+
if (quote !== undefined) throw new Error('cli has an unterminated quote')
|
|
58
|
+
if (tokenStarted) tokens.push(token)
|
|
59
|
+
if (tokens.length === 0 || tokens[0] === '') throw new Error('cli executable cannot be empty')
|
|
60
|
+
return tokens
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateVersion(raw) {
|
|
64
|
+
const version = String(raw ?? '').trim()
|
|
65
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
66
|
+
throw new Error(`argus-version must be a pinned semver, got: ${version || '<empty>'}`)
|
|
67
|
+
}
|
|
68
|
+
return version
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateBrowser(raw) {
|
|
72
|
+
const browser = String(raw ?? 'chromium').trim()
|
|
73
|
+
if (!['chromium', 'firefox', 'webkit'].includes(browser)) {
|
|
74
|
+
throw new Error(`browser must be chromium, firefox, or webkit; got: ${browser || '<empty>'}`)
|
|
75
|
+
}
|
|
76
|
+
return browser
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateBudget(raw, name = 'budget-usd') {
|
|
80
|
+
if (raw === undefined || raw === '') return undefined
|
|
81
|
+
const value = Number(raw)
|
|
82
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
83
|
+
throw new Error(`${name} must be a positive number`)
|
|
84
|
+
}
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function validateMaxComments(raw) {
|
|
89
|
+
if (raw === undefined || raw === '') return undefined
|
|
90
|
+
if (!/^\d+$/.test(String(raw).trim())) {
|
|
91
|
+
throw new Error('max-comments must be a non-negative integer')
|
|
92
|
+
}
|
|
93
|
+
return Number(raw)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateCodeModel(raw) {
|
|
97
|
+
if (raw === undefined || raw === '') return undefined
|
|
98
|
+
const value = String(raw).trim()
|
|
99
|
+
// OpenRouter slugs are `vendor/model` with an optional `:variant`
|
|
100
|
+
// (`deepseek/deepseek-r1:free`). Env-only value — no shell — but keep it
|
|
101
|
+
// to slug-shaped input so typos surface here instead of at the API.
|
|
102
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(value)) {
|
|
103
|
+
throw new Error('code-model must be an OpenRouter model slug (e.g. openai/gpt-5-mini)')
|
|
104
|
+
}
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveWorkingDirectory(workspace, input) {
|
|
109
|
+
const root = realpathSync(resolve(workspace))
|
|
110
|
+
const requested = String(input ?? '').trim()
|
|
111
|
+
const unresolvedCwd = requested === '' ? root : resolve(root, requested)
|
|
112
|
+
const unresolvedRel = relative(root, unresolvedCwd)
|
|
113
|
+
if (unresolvedRel === '..' || unresolvedRel.startsWith(`..${sep}`) || isAbsolute(unresolvedRel)) {
|
|
114
|
+
throw new Error('working-directory must stay inside GITHUB_WORKSPACE')
|
|
115
|
+
}
|
|
116
|
+
const cwd = realpathSync(unresolvedCwd)
|
|
117
|
+
const rel = relative(root, cwd)
|
|
118
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
119
|
+
throw new Error('working-directory must stay inside GITHUB_WORKSPACE')
|
|
120
|
+
}
|
|
121
|
+
return unresolvedCwd
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validatePathInput(raw, name) {
|
|
125
|
+
const value = String(raw ?? '')
|
|
126
|
+
if (value.includes('\0') || /[\r\n]/.test(value)) {
|
|
127
|
+
throw new Error(`${name} contains an invalid control character`)
|
|
128
|
+
}
|
|
129
|
+
return value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function assertRegularFileInside(root, file, name) {
|
|
133
|
+
const unresolvedRoot = resolve(root)
|
|
134
|
+
const unresolvedFile = resolve(unresolvedRoot, file)
|
|
135
|
+
const unresolvedRel = relative(unresolvedRoot, unresolvedFile)
|
|
136
|
+
if (unresolvedRel === '..' || unresolvedRel.startsWith(`..${sep}`) || isAbsolute(unresolvedRel)) {
|
|
137
|
+
throw new Error(`${name} must stay inside the working directory`)
|
|
138
|
+
}
|
|
139
|
+
const rootPath = realpathSync(unresolvedRoot)
|
|
140
|
+
const filePath = realpathSync(unresolvedFile)
|
|
141
|
+
const rel = relative(rootPath, filePath)
|
|
142
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
143
|
+
throw new Error(`${name} must stay inside the working directory`)
|
|
144
|
+
}
|
|
145
|
+
return unresolvedFile
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function assertRegularFile(path, name) {
|
|
149
|
+
const stat = await lstat(path)
|
|
150
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
151
|
+
throw new Error(`${name} must be a regular non-symlink file: ${path}`)
|
|
152
|
+
}
|
|
153
|
+
return path
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setActionOutput(name, value) {
|
|
157
|
+
const output = process.env.GITHUB_OUTPUT
|
|
158
|
+
const line = `${name}=${String(value).replace(/[\r\n]/g, '')}`
|
|
159
|
+
if (output === undefined || output === '') {
|
|
160
|
+
console.log(line)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
// GITHUB_OUTPUT is supplied by the Actions runner. This synchronous append
|
|
164
|
+
// keeps bootstrap deterministic before the next composite step starts.
|
|
165
|
+
appendFileSync(output, `${line}\n`, 'utf8')
|
|
166
|
+
}
|