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,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,104 @@
|
|
|
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
|
+
validateCodeModel,
|
|
17
|
+
validateMaxComments,
|
|
18
|
+
validatePathInput,
|
|
19
|
+
validateVersion,
|
|
20
|
+
} from './runtime.mjs'
|
|
21
|
+
|
|
22
|
+
const actionDir = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const workspace = resolve(process.env.GITHUB_WORKSPACE || process.cwd())
|
|
24
|
+
const cwd = resolveWorkingDirectory(workspace, process.env.ARGUS_WORKING_DIRECTORY)
|
|
25
|
+
const configInput = validatePathInput(process.env.ARGUS_CONFIG_PATH, 'config')
|
|
26
|
+
const reportDir = validatePathInput(
|
|
27
|
+
process.env.ARGUS_REPORT_DIR || 'argus-reviewer-report',
|
|
28
|
+
'report-dir',
|
|
29
|
+
)
|
|
30
|
+
validateBrowser(process.env.ARGUS_BROWSER || 'chromium')
|
|
31
|
+
validateBudget(process.env.ARGUS_BUDGET_USD)
|
|
32
|
+
validateMaxComments(process.env.ARGUS_MAX_COMMENTS)
|
|
33
|
+
validateCodeModel(process.env.ARGUS_CODE_MODEL)
|
|
34
|
+
|
|
35
|
+
async function stageConfig() {
|
|
36
|
+
if (configInput === '' || configInput === 'argus-reviewer.config.ts') return
|
|
37
|
+
const ext = configInput.slice(configInput.lastIndexOf('.')).toLowerCase()
|
|
38
|
+
if (ext !== '.ts' && ext !== '.json') {
|
|
39
|
+
throw new Error(`config must use .ts or .json, got: ${configInput}`)
|
|
40
|
+
}
|
|
41
|
+
const source = await assertRegularFile(
|
|
42
|
+
assertRegularFileInside(cwd, configInput, 'config'),
|
|
43
|
+
'config',
|
|
44
|
+
)
|
|
45
|
+
if (source === join(cwd, `argus-reviewer.config${ext}`)) return
|
|
46
|
+
await copyFile(source, join(cwd, `argus-reviewer.config${ext}`))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await stageConfig()
|
|
50
|
+
|
|
51
|
+
let cli
|
|
52
|
+
const override = process.env.ARGUS_CLI?.trim() ?? ''
|
|
53
|
+
if (override !== '') {
|
|
54
|
+
cli = parseCommand(override)
|
|
55
|
+
} else {
|
|
56
|
+
const version = process.env.ARGUS_ARGUS_VERSION?.trim() ?? ''
|
|
57
|
+
if (version === '') {
|
|
58
|
+
const runtime = await mkdtemp(join(tmpdir(), 'argus-reviewer-action-pinned-'))
|
|
59
|
+
const result = spawnSync(
|
|
60
|
+
'npm',
|
|
61
|
+
[
|
|
62
|
+
'install',
|
|
63
|
+
'--prefix',
|
|
64
|
+
runtime,
|
|
65
|
+
'--ignore-scripts',
|
|
66
|
+
'--no-audit',
|
|
67
|
+
'--no-fund',
|
|
68
|
+
'--no-package-lock',
|
|
69
|
+
'--no-save',
|
|
70
|
+
resolve(actionDir, '..'),
|
|
71
|
+
],
|
|
72
|
+
{ stdio: 'inherit', shell: false },
|
|
73
|
+
)
|
|
74
|
+
if (result.status !== 0) throw new Error('could not install the CLI from the pinned action ref')
|
|
75
|
+
cli = [process.execPath, join(runtime, 'node_modules', 'argus-reviewer-e2e', 'dist', 'cli.js')]
|
|
76
|
+
} else {
|
|
77
|
+
validateVersion(version)
|
|
78
|
+
const runtime = await mkdtemp(join(tmpdir(), `argus-reviewer-action-${version}-`))
|
|
79
|
+
const result = spawnSync(
|
|
80
|
+
'npm',
|
|
81
|
+
[
|
|
82
|
+
'install',
|
|
83
|
+
'--prefix',
|
|
84
|
+
runtime,
|
|
85
|
+
'--ignore-scripts',
|
|
86
|
+
'--no-audit',
|
|
87
|
+
'--no-fund',
|
|
88
|
+
'--no-package-lock',
|
|
89
|
+
'--no-save',
|
|
90
|
+
`argus-reviewer-e2e@${version}`,
|
|
91
|
+
],
|
|
92
|
+
{ stdio: 'inherit', shell: false },
|
|
93
|
+
)
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
throw new Error(`could not bootstrap pinned argus-reviewer-e2e@${version}`)
|
|
96
|
+
}
|
|
97
|
+
cli = [process.execPath, join(runtime, 'node_modules', 'argus-reviewer-e2e', 'dist', 'cli.js')]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setActionOutput('cli-json', JSON.stringify(cli))
|
|
102
|
+
setActionOutput('working-directory', cwd)
|
|
103
|
+
setActionOutput('report-dir', reportDir)
|
|
104
|
+
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
|
+
})
|