@toast-ninja/toast-cli 0.1.4 → 0.1.6
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 +37 -9
- package/package.json +1 -1
- package/skills/review-ci/SKILL.md +30 -9
- package/src/cli.js +324 -16
package/README.md
CHANGED
|
@@ -36,6 +36,15 @@ scope:
|
|
|
36
36
|
toast auth --org toast-ninja
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
You can authenticate multiple orgs on the same machine. The CLI stores tokens by
|
|
40
|
+
org and picks the token that matches `--repo`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
toast auth --org toast-ninja
|
|
44
|
+
toast auth --org another-org
|
|
45
|
+
toast review-ci next --repo another-org/backend --pr 1177 --head "$GITHUB_SHA"
|
|
46
|
+
```
|
|
47
|
+
|
|
39
48
|
If browser auth is not available, use GitHub device auth explicitly:
|
|
40
49
|
|
|
41
50
|
```bash
|
|
@@ -65,9 +74,13 @@ toast review-ci wait \
|
|
|
65
74
|
--timeout 20m
|
|
66
75
|
```
|
|
67
76
|
|
|
68
|
-
`wait` exits `0` only when Toast returns `
|
|
77
|
+
`wait` exits `0` only when Toast returns `ready` for the exact head SHA. It exits `1` for actionable non-green states and `2` for auth, network, argument, or timeout errors.
|
|
69
78
|
It waits on Toast's server-side Review CI events through bounded requests, so a
|
|
70
|
-
local polling interval is not needed.
|
|
79
|
+
local polling interval is not needed. If a wait stays pending, the CLI prints
|
|
80
|
+
the current `waiting_for` reason to stderr; when a bounded wait wakes from a
|
|
81
|
+
webhook or timeout, Toast reconciles exact GitHub PR state when needed.
|
|
82
|
+
Reconciled responses include concrete GitHub check names in
|
|
83
|
+
`github_checks` / `waiting_for.github_checks` when CI is pending or failing.
|
|
71
84
|
|
|
72
85
|
CI can also provide a token directly:
|
|
73
86
|
|
|
@@ -89,12 +102,17 @@ toast review-ci next \
|
|
|
89
102
|
--head "$GITHUB_SHA"
|
|
90
103
|
```
|
|
91
104
|
|
|
92
|
-
Install agent guidance
|
|
105
|
+
Install agent guidance from the project checkout:
|
|
93
106
|
|
|
94
107
|
```bash
|
|
95
|
-
toast review-ci instructions --install
|
|
108
|
+
toast review-ci instructions --install
|
|
96
109
|
```
|
|
97
110
|
|
|
111
|
+
This installs the Codex skill and writes a bounded Toast Review CI section to
|
|
112
|
+
`CLAUDE.md` and `AGENTS.md`. The `--install-agent` flag is an alias for
|
|
113
|
+
`--install`; the older `--install-codex` flag still installs only the Codex
|
|
114
|
+
skill.
|
|
115
|
+
|
|
98
116
|
Or print the guidance:
|
|
99
117
|
|
|
100
118
|
```bash
|
|
@@ -103,12 +121,22 @@ toast review-ci instructions
|
|
|
103
121
|
|
|
104
122
|
## AI Agent Instructions
|
|
105
123
|
|
|
106
|
-
Before using `gh` to
|
|
124
|
+
Use Review CI as the source of truth for PR readiness. Before using `gh` to
|
|
125
|
+
rediscover review context, run:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Keep iterating until the response status is `ready`. For `needs_fix`, fix every
|
|
132
|
+
`blocking_items` entry, reply with what changed, resolve threads when requested,
|
|
133
|
+
push, refresh the exact head SHA, then run:
|
|
107
134
|
|
|
108
135
|
```bash
|
|
109
|
-
toast review-ci
|
|
136
|
+
toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
|
|
110
137
|
```
|
|
111
138
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
If `wait` returns `ready`, report completion. If it returns `needs_fix`, use the
|
|
140
|
+
returned `blocking_items` and continue the fix loop instead of claiming the PR
|
|
141
|
+
is done. For `pending` or `degraded:reconciliation_incomplete`, run `wait` for
|
|
142
|
+
the same head SHA and use `waiting_for` to see what Toast is waiting on.
|
package/package.json
CHANGED
|
@@ -5,21 +5,42 @@ description: Use when addressing GitHub pull request review feedback in a repo t
|
|
|
5
5
|
|
|
6
6
|
# Toast Review CI
|
|
7
7
|
|
|
8
|
-
Use Toast Review CI
|
|
8
|
+
Use Toast Review CI as the source of truth for PR review readiness before
|
|
9
|
+
rediscovering review context with `gh`.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Goal: keep iterating until Review CI returns `status: "ready"` for the exact
|
|
12
|
+
current head SHA. Only report completion after Review CI returns `ready`.
|
|
13
|
+
|
|
14
|
+
## Review Loop
|
|
15
|
+
|
|
16
|
+
1. Get the current PR head SHA, then run:
|
|
11
17
|
|
|
12
18
|
```bash
|
|
13
|
-
toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA
|
|
19
|
+
toast review-ci next --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
|
|
14
20
|
```
|
|
15
21
|
|
|
16
|
-
2.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
2. Follow the returned status:
|
|
23
|
+
- `ready`: the PR is ready. Report completion.
|
|
24
|
+
- `needs_fix`: fix every `blocking_items` entry. Prefer the URL and body
|
|
25
|
+
from Toast over a fresh GitHub search.
|
|
26
|
+
- `pending` or `degraded:reconciliation_incomplete`: run
|
|
27
|
+
`toast review-ci wait` for the same head SHA, using `waiting_for` to see
|
|
28
|
+
what Toast is waiting on, then continue from step 6.
|
|
29
|
+
- `head_changed`: refresh the head SHA and restart the loop.
|
|
30
|
+
- `draft`, `github_blocked`, `policy_blocked:unknown_actor`, or
|
|
31
|
+
`degraded:timeout`: fix the blocker when possible; otherwise report the
|
|
32
|
+
blocker instead of claiming the PR is ready.
|
|
33
|
+
3. When fixing `blocking_items`, reply with what changed when a reply is
|
|
34
|
+
requested. Include the relevant commit link if available.
|
|
35
|
+
4. Resolve review threads when
|
|
36
|
+
`suggested_reply_requirements.resolve_thread_required` is true.
|
|
37
|
+
5. Push the fix, refresh the exact new head SHA, then wait:
|
|
20
38
|
|
|
21
39
|
```bash
|
|
22
|
-
toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA
|
|
40
|
+
toast review-ci wait --repo ORG/REPO --pr NUMBER --head HEAD_SHA --json
|
|
23
41
|
```
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
6. If `wait` returns `ready`, report completion. If it returns `needs_fix`, use
|
|
44
|
+
the returned `blocking_items` and continue the fix loop. If it returns
|
|
45
|
+
`head_changed` or another non-ready status, continue from the matching step
|
|
46
|
+
above.
|
package/src/cli.js
CHANGED
|
@@ -13,11 +13,14 @@ const { requestJson } = require('./http')
|
|
|
13
13
|
const DEFAULT_API_URL = 'https://api.toast.ninja'
|
|
14
14
|
const DEFAULT_WAIT_REQUEST_SECONDS = 25
|
|
15
15
|
const WAIT_REQUEST_GRACE_MS = 5000
|
|
16
|
+
const WAIT_DIAGNOSTIC_INTERVAL_MS = 60 * 1000
|
|
17
|
+
const WAIT_STALLED_AFTER_MS = 5 * 60 * 1000
|
|
16
18
|
const WAIT_STATUSES = new Set([
|
|
17
19
|
'pending',
|
|
18
20
|
'degraded:reconciliation_incomplete',
|
|
19
21
|
'head_changed',
|
|
20
22
|
])
|
|
23
|
+
const READY_STATUSES = new Set(['ready', 'acknowledged'])
|
|
21
24
|
const REVIEW_CI_SKILL_PATH = path.join(
|
|
22
25
|
__dirname,
|
|
23
26
|
'..',
|
|
@@ -25,6 +28,8 @@ const REVIEW_CI_SKILL_PATH = path.join(
|
|
|
25
28
|
'review-ci',
|
|
26
29
|
'SKILL.md',
|
|
27
30
|
)
|
|
31
|
+
const REVIEW_CI_INSTRUCTIONS_START = '<!-- toast-review-ci:start -->'
|
|
32
|
+
const REVIEW_CI_INSTRUCTIONS_END = '<!-- toast-review-ci:end -->'
|
|
28
33
|
const REVIEW_CI_COMMANDS = new Set(['review-ci'])
|
|
29
34
|
const AUTH_COMMANDS = new Set(['auth', 'login'])
|
|
30
35
|
|
|
@@ -172,8 +177,44 @@ const normalizeApiUrl = apiUrl =>
|
|
|
172
177
|
const getApiUrl = ({ options, env, config }) =>
|
|
173
178
|
normalizeApiUrl(options['api-url'] || env.TOAST_API_URL || config.apiUrl)
|
|
174
179
|
|
|
175
|
-
const
|
|
176
|
-
|
|
180
|
+
const getScopedTokenEntry = ({ config, repo }) => {
|
|
181
|
+
if (!repo) return null
|
|
182
|
+
|
|
183
|
+
const repoEntry = config.tokensByRepo?.[repo.fullName]
|
|
184
|
+
if (repoEntry?.token) return repoEntry
|
|
185
|
+
|
|
186
|
+
const orgEntry = config.tokensByOrg?.[repo.owner]
|
|
187
|
+
if (orgEntry?.token) return orgEntry
|
|
188
|
+
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const legacyConfigMatchesRepo = ({ config, repo }) => {
|
|
193
|
+
if (!config.token) return false
|
|
194
|
+
if (!config.org && !config.repo) return false
|
|
195
|
+
|
|
196
|
+
if (config.repo) {
|
|
197
|
+
try {
|
|
198
|
+
return parseRepo(config.repo).fullName === repo.fullName
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return config.org === repo.owner
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const getToken = ({ options, env, config, repo }) => {
|
|
208
|
+
if (options.token) return options.token
|
|
209
|
+
if (env.TOAST_TOKEN) return env.TOAST_TOKEN
|
|
210
|
+
|
|
211
|
+
const scopedEntry = getScopedTokenEntry({ config, repo })
|
|
212
|
+
if (scopedEntry) return scopedEntry.token
|
|
213
|
+
|
|
214
|
+
if (legacyConfigMatchesRepo({ config, repo })) return config.token
|
|
215
|
+
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
177
218
|
|
|
178
219
|
const toMillis = value => {
|
|
179
220
|
if (value instanceof Date) return value.getTime()
|
|
@@ -195,6 +236,16 @@ const parseDurationMs = value => {
|
|
|
195
236
|
return amount
|
|
196
237
|
}
|
|
197
238
|
|
|
239
|
+
const formatDurationMs = ms => {
|
|
240
|
+
const seconds = Math.max(0, Math.round(Number(ms) / 1000))
|
|
241
|
+
if (seconds < 60) return `${seconds}s`
|
|
242
|
+
|
|
243
|
+
const minutes = Math.floor(seconds / 60)
|
|
244
|
+
const remainingSeconds = seconds % 60
|
|
245
|
+
|
|
246
|
+
return remainingSeconds ? `${minutes}m${remainingSeconds}s` : `${minutes}m`
|
|
247
|
+
}
|
|
248
|
+
|
|
198
249
|
const parseBrowserCallbackAuth = ({ requestUrl, expectedState }) => {
|
|
199
250
|
const callbackUrl = new URL(requestUrl, 'http://127.0.0.1')
|
|
200
251
|
const state = callbackUrl.searchParams.get('state')
|
|
@@ -334,10 +385,12 @@ const requestReviewState = async ({
|
|
|
334
385
|
const pullNumber = requireOption(options, 'pr')
|
|
335
386
|
const head = requireOption(options, 'head')
|
|
336
387
|
const apiUrl = getApiUrl({ options, env, config })
|
|
337
|
-
const token = getToken({ options, env, config })
|
|
388
|
+
const token = getToken({ options, env, config, repo })
|
|
338
389
|
|
|
339
390
|
if (!token)
|
|
340
|
-
throw new Error(
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Missing Toast token for ${repo.owner}. Run \`toast auth --org ${repo.owner}\` or set TOAST_TOKEN.`,
|
|
393
|
+
)
|
|
341
394
|
|
|
342
395
|
const requestOptions = {
|
|
343
396
|
method: 'GET',
|
|
@@ -396,6 +449,75 @@ const writeReviewState = ({ state, stdout, asJson, includeBlockingItems }) => {
|
|
|
396
449
|
})
|
|
397
450
|
}
|
|
398
451
|
|
|
452
|
+
const toList = values => (values || []).filter(Boolean).join(', ')
|
|
453
|
+
|
|
454
|
+
const getWaitReasonParts = state => {
|
|
455
|
+
const waitingFor = state.waiting_for || {}
|
|
456
|
+
const reasons = []
|
|
457
|
+
|
|
458
|
+
if (waitingFor.pending_actors?.length) {
|
|
459
|
+
reasons.push(`open review request for ${toList(waitingFor.pending_actors)}`)
|
|
460
|
+
}
|
|
461
|
+
if (waitingFor.blocking_actors?.length) {
|
|
462
|
+
reasons.push(
|
|
463
|
+
`missing current-head review signal from ${toList(
|
|
464
|
+
waitingFor.blocking_actors,
|
|
465
|
+
)}`,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
if (waitingFor.github_mergeability) {
|
|
469
|
+
reasons.push(`GitHub mergeability is ${waitingFor.github_mergeability}`)
|
|
470
|
+
}
|
|
471
|
+
if (waitingFor.github_checks?.length) {
|
|
472
|
+
reasons.push(`GitHub checks: ${toList(waitingFor.github_checks)}`)
|
|
473
|
+
}
|
|
474
|
+
if (state.status === 'head_changed') {
|
|
475
|
+
reasons.push(
|
|
476
|
+
`Toast has head ${state.head || '<unknown>'}; requested ${
|
|
477
|
+
state.requested_head || '<unknown>'
|
|
478
|
+
}`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
if (state.status === 'degraded:reconciliation_incomplete') {
|
|
482
|
+
reasons.push('exact GitHub reconciliation is incomplete; retrying')
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return reasons
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const getWaitStateKey = state =>
|
|
489
|
+
JSON.stringify({
|
|
490
|
+
status: state.status,
|
|
491
|
+
head: state.head,
|
|
492
|
+
next_action: state.next_action,
|
|
493
|
+
counts: state.counts,
|
|
494
|
+
waiting_for: state.waiting_for,
|
|
495
|
+
github_mergeability: state.github_mergeability,
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const writeWaitDiagnostic = ({ state, stderr, elapsedMs, unchangedMs }) => {
|
|
499
|
+
const reasons = getWaitReasonParts(state)
|
|
500
|
+
const reasonText = reasons.length
|
|
501
|
+
? reasons.join('; ')
|
|
502
|
+
: 'Toast did not return waiting_for details; run `toast review-ci next --json` to inspect the current state'
|
|
503
|
+
|
|
504
|
+
stderr.write(
|
|
505
|
+
`Review CI still ${state.status} after ${formatDurationMs(
|
|
506
|
+
elapsedMs,
|
|
507
|
+
)}: ${reasonText}.\n`,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
if (unchangedMs >= WAIT_STALLED_AFTER_MS) {
|
|
511
|
+
stderr.write(
|
|
512
|
+
`Review CI state has not changed for ${formatDurationMs(
|
|
513
|
+
unchangedMs,
|
|
514
|
+
)}; inspect GitHub review requests/checks, re-request the stuck reviewer, or report this blocker.\n`,
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const isReadyState = state => READY_STATUSES.has(state.status)
|
|
520
|
+
|
|
399
521
|
const handleStatus = async context => {
|
|
400
522
|
const config = context.readConfig()
|
|
401
523
|
const { options } = parseArgs(getReviewStateArgs(context))
|
|
@@ -403,7 +525,7 @@ const handleStatus = async context => {
|
|
|
403
525
|
|
|
404
526
|
writeReviewState({ state, stdout: context.stdout, asJson: options.json })
|
|
405
527
|
|
|
406
|
-
return state
|
|
528
|
+
return isReadyState(state) ? 0 : 1
|
|
407
529
|
}
|
|
408
530
|
|
|
409
531
|
const handleNext = async context => {
|
|
@@ -423,7 +545,7 @@ const handleNext = async context => {
|
|
|
423
545
|
includeBlockingItems: true,
|
|
424
546
|
})
|
|
425
547
|
|
|
426
|
-
return state
|
|
548
|
+
return isReadyState(state) ? 0 : 1
|
|
427
549
|
}
|
|
428
550
|
|
|
429
551
|
const handleWait = async context => {
|
|
@@ -432,6 +554,9 @@ const handleWait = async context => {
|
|
|
432
554
|
const timeoutMs = parseDurationMs(options.timeout || '20m')
|
|
433
555
|
if (options.interval) parseDurationMs(options.interval)
|
|
434
556
|
const startedAt = toMillis(context.now())
|
|
557
|
+
let lastStateKey = null
|
|
558
|
+
let lastStateChangedAt = startedAt
|
|
559
|
+
let lastDiagnosticAt = null
|
|
435
560
|
|
|
436
561
|
while (true) {
|
|
437
562
|
const elapsedMs = toMillis(context.now()) - startedAt
|
|
@@ -453,7 +578,7 @@ const handleWait = async context => {
|
|
|
453
578
|
timeoutMs: waitTimeoutSeconds * 1000 + WAIT_REQUEST_GRACE_MS,
|
|
454
579
|
})
|
|
455
580
|
|
|
456
|
-
if (state
|
|
581
|
+
if (isReadyState(state)) {
|
|
457
582
|
writeReviewState({ state, stdout: context.stdout, asJson: options.json })
|
|
458
583
|
return 0
|
|
459
584
|
}
|
|
@@ -462,34 +587,106 @@ const handleWait = async context => {
|
|
|
462
587
|
writeReviewState({ state, stdout: context.stdout, asJson: options.json })
|
|
463
588
|
return 1
|
|
464
589
|
}
|
|
590
|
+
|
|
591
|
+
const observedAt = toMillis(context.now())
|
|
592
|
+
const stateKey = getWaitStateKey(state)
|
|
593
|
+
if (stateKey !== lastStateKey) {
|
|
594
|
+
lastStateKey = stateKey
|
|
595
|
+
lastStateChangedAt = observedAt
|
|
596
|
+
}
|
|
597
|
+
if (
|
|
598
|
+
lastDiagnosticAt === null ||
|
|
599
|
+
observedAt - lastDiagnosticAt >= WAIT_DIAGNOSTIC_INTERVAL_MS
|
|
600
|
+
) {
|
|
601
|
+
writeWaitDiagnostic({
|
|
602
|
+
state,
|
|
603
|
+
stderr: context.stderr,
|
|
604
|
+
elapsedMs: observedAt - startedAt,
|
|
605
|
+
unchangedMs: observedAt - lastStateChangedAt,
|
|
606
|
+
})
|
|
607
|
+
lastDiagnosticAt = observedAt
|
|
608
|
+
}
|
|
465
609
|
}
|
|
466
610
|
|
|
467
|
-
context.stderr.write('Timed out waiting for Review CI
|
|
611
|
+
context.stderr.write('Timed out waiting for Review CI readiness\n')
|
|
468
612
|
return 2
|
|
469
613
|
}
|
|
470
614
|
|
|
471
615
|
const completeLogin = ({ context, auth, apiUrl }) => {
|
|
472
616
|
const existingConfig = { ...context.readConfig() }
|
|
473
|
-
const
|
|
617
|
+
const repo = auth.repo ? parseRepo(auth.repo).fullName : null
|
|
618
|
+
const org = auth.org || (repo ? parseRepo(repo).owner : null)
|
|
474
619
|
|
|
475
620
|
if (!org) throw new Error('Login response was missing org')
|
|
476
|
-
delete existingConfig.repo
|
|
477
621
|
|
|
478
622
|
const expiresAt = new Date(
|
|
479
623
|
toMillis(context.now()) + auth.expires_in * 1000,
|
|
480
624
|
).toISOString()
|
|
625
|
+
const tokensByOrg = { ...(existingConfig.tokensByOrg || {}) }
|
|
626
|
+
const tokensByRepo = { ...(existingConfig.tokensByRepo || {}) }
|
|
627
|
+
|
|
628
|
+
if (existingConfig.token) {
|
|
629
|
+
const legacyEntry = {
|
|
630
|
+
token: existingConfig.token,
|
|
631
|
+
...(existingConfig.githubLogin
|
|
632
|
+
? { githubLogin: existingConfig.githubLogin }
|
|
633
|
+
: {}),
|
|
634
|
+
...(existingConfig.expiresAt ? { expiresAt: existingConfig.expiresAt } : {}),
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (existingConfig.repo) {
|
|
638
|
+
try {
|
|
639
|
+
const legacyRepo = parseRepo(existingConfig.repo)
|
|
640
|
+
tokensByRepo[legacyRepo.fullName] = {
|
|
641
|
+
...legacyEntry,
|
|
642
|
+
org: legacyRepo.owner,
|
|
643
|
+
repo: legacyRepo.fullName,
|
|
644
|
+
}
|
|
645
|
+
} catch (e) {
|
|
646
|
+
// Ignore malformed legacy scope and keep the new login usable.
|
|
647
|
+
}
|
|
648
|
+
} else if (existingConfig.org) {
|
|
649
|
+
tokensByOrg[existingConfig.org] = {
|
|
650
|
+
...legacyEntry,
|
|
651
|
+
org: existingConfig.org,
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
// An unscoped legacy token has no safe owner to migrate.
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const entry = {
|
|
659
|
+
token: auth.token,
|
|
660
|
+
githubLogin: auth.github_login,
|
|
661
|
+
expiresAt,
|
|
662
|
+
org,
|
|
663
|
+
...(repo ? { repo } : {}),
|
|
664
|
+
}
|
|
665
|
+
if (repo) {
|
|
666
|
+
tokensByRepo[repo] = entry
|
|
667
|
+
} else {
|
|
668
|
+
tokensByOrg[org] = entry
|
|
669
|
+
}
|
|
481
670
|
|
|
482
|
-
|
|
671
|
+
const nextConfig = {
|
|
483
672
|
...existingConfig,
|
|
484
673
|
apiUrl,
|
|
485
674
|
org,
|
|
675
|
+
...(repo ? { repo } : {}),
|
|
486
676
|
token: auth.token,
|
|
487
677
|
githubLogin: auth.github_login,
|
|
488
678
|
expiresAt,
|
|
489
|
-
|
|
490
|
-
|
|
679
|
+
tokensByOrg,
|
|
680
|
+
tokensByRepo,
|
|
681
|
+
}
|
|
682
|
+
if (!repo) delete nextConfig.repo
|
|
683
|
+
if (!Object.keys(tokensByOrg).length) delete nextConfig.tokensByOrg
|
|
684
|
+
if (!Object.keys(tokensByRepo).length) delete nextConfig.tokensByRepo
|
|
685
|
+
|
|
686
|
+
context.writeConfig(nextConfig)
|
|
687
|
+
context.stdout.write(`Authenticated ${auth.github_login} for ${repo || org}\n`)
|
|
491
688
|
context.stdout.write(
|
|
492
|
-
'Agent setup: run `toast review-ci instructions --install
|
|
689
|
+
'Agent setup: run `toast review-ci instructions --install` to install Review CI guidance.\n',
|
|
493
690
|
)
|
|
494
691
|
}
|
|
495
692
|
|
|
@@ -646,6 +843,9 @@ const handleAuth = async context => {
|
|
|
646
843
|
|
|
647
844
|
const readReviewCiSkill = () => fs.readFileSync(REVIEW_CI_SKILL_PATH, 'utf8')
|
|
648
845
|
|
|
846
|
+
const stripSkillFrontmatter = markdown =>
|
|
847
|
+
markdown.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n)*/, '')
|
|
848
|
+
|
|
649
849
|
const getCodexSkillPath = env =>
|
|
650
850
|
path.join(
|
|
651
851
|
env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
@@ -659,12 +859,118 @@ const installCodexSkill = ({ env, stdout }) => {
|
|
|
659
859
|
|
|
660
860
|
fs.mkdirSync(path.dirname(skillPath), { recursive: true, mode: 0o700 })
|
|
661
861
|
fs.writeFileSync(skillPath, readReviewCiSkill(), { mode: 0o600 })
|
|
662
|
-
stdout.write(`Installed Review CI
|
|
862
|
+
stdout.write(`Installed Review CI Codex skill to ${skillPath}\n`)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const buildMarkdownInstructionsSection = () =>
|
|
866
|
+
`${REVIEW_CI_INSTRUCTIONS_START}\n${stripSkillFrontmatter(
|
|
867
|
+
readReviewCiSkill(),
|
|
868
|
+
).trim()}\n${REVIEW_CI_INSTRUCTIONS_END}\n`
|
|
869
|
+
|
|
870
|
+
const getMalformedMarkerError = fileName =>
|
|
871
|
+
`${fileName} has a Toast Review CI start marker without an end marker; repair the marked section and rerun.`
|
|
872
|
+
|
|
873
|
+
const removeMarkedInstructionFragments = ({ content, fileName }) => {
|
|
874
|
+
let next = content
|
|
875
|
+
|
|
876
|
+
while (next.includes(REVIEW_CI_INSTRUCTIONS_START)) {
|
|
877
|
+
const start = next.indexOf(REVIEW_CI_INSTRUCTIONS_START)
|
|
878
|
+
const end = next.indexOf(REVIEW_CI_INSTRUCTIONS_END, start)
|
|
879
|
+
|
|
880
|
+
if (end === -1) {
|
|
881
|
+
throw new Error(getMalformedMarkerError(fileName))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
next = `${next.slice(0, start)}${next.slice(
|
|
885
|
+
end + REVIEW_CI_INSTRUCTIONS_END.length,
|
|
886
|
+
)}`
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return next.split(REVIEW_CI_INSTRUCTIONS_END).join('')
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const upsertMarkedSection = ({ content, fileName, section }) => {
|
|
893
|
+
const start = content.indexOf(REVIEW_CI_INSTRUCTIONS_START)
|
|
894
|
+
const end = content.indexOf(REVIEW_CI_INSTRUCTIONS_END, start)
|
|
895
|
+
|
|
896
|
+
if (start !== -1) {
|
|
897
|
+
if (end === -1) {
|
|
898
|
+
throw new Error(getMalformedMarkerError(fileName))
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const cleanedBefore = removeMarkedInstructionFragments({
|
|
902
|
+
content: content.slice(0, start),
|
|
903
|
+
fileName,
|
|
904
|
+
})
|
|
905
|
+
const before = cleanedBefore.trim()
|
|
906
|
+
? `${cleanedBefore.replace(/\s*$/, '')}\n\n`
|
|
907
|
+
: ''
|
|
908
|
+
const after = removeMarkedInstructionFragments({
|
|
909
|
+
content: content.slice(end + REVIEW_CI_INSTRUCTIONS_END.length),
|
|
910
|
+
fileName,
|
|
911
|
+
}).replace(/^\s*/, '')
|
|
912
|
+
|
|
913
|
+
return `${before}${section}${after ? `\n${after}` : ''}`
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const cleaned = removeMarkedInstructionFragments({ content, fileName })
|
|
917
|
+
const prefix = cleaned.trim() ? `${cleaned.replace(/\s*$/, '')}\n\n` : ''
|
|
918
|
+
|
|
919
|
+
return `${prefix}${section}`
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const getMarkdownInstructionsUpdate = ({ cwd, fileName }) => {
|
|
923
|
+
const filePath = path.join(cwd, fileName)
|
|
924
|
+
const exists = fs.existsSync(filePath)
|
|
925
|
+
const existing = exists ? fs.readFileSync(filePath, 'utf8') : ''
|
|
926
|
+
const next = upsertMarkedSection({
|
|
927
|
+
content: existing,
|
|
928
|
+
fileName,
|
|
929
|
+
section: buildMarkdownInstructionsSection(),
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
exists,
|
|
934
|
+
fileName,
|
|
935
|
+
filePath,
|
|
936
|
+
next,
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const writeMarkdownInstructionsUpdate = ({ update, stdout }) => {
|
|
941
|
+
if (update.exists) {
|
|
942
|
+
fs.writeFileSync(update.filePath, update.next)
|
|
943
|
+
} else {
|
|
944
|
+
fs.writeFileSync(update.filePath, update.next, { mode: 0o644 })
|
|
945
|
+
}
|
|
946
|
+
stdout.write(
|
|
947
|
+
`Installed Review CI ${update.fileName} instructions to ${update.filePath}\n`,
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const installAgentInstructions = ({ cwd, env, stdout }) => {
|
|
952
|
+
const markdownUpdates = ['CLAUDE.md', 'AGENTS.md'].map(fileName =>
|
|
953
|
+
getMarkdownInstructionsUpdate({ cwd, fileName }),
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
markdownUpdates.forEach(update =>
|
|
957
|
+
writeMarkdownInstructionsUpdate({ update, stdout }),
|
|
958
|
+
)
|
|
959
|
+
installCodexSkill({ env, stdout })
|
|
663
960
|
}
|
|
664
961
|
|
|
665
962
|
const handleInstructions = async context => {
|
|
666
963
|
const { options } = parseArgs(context.commandArgs || [])
|
|
667
964
|
|
|
965
|
+
if (options.install || options['install-agent']) {
|
|
966
|
+
installAgentInstructions({
|
|
967
|
+
cwd: context.cwd,
|
|
968
|
+
env: context.env,
|
|
969
|
+
stdout: context.stdout,
|
|
970
|
+
})
|
|
971
|
+
return 0
|
|
972
|
+
}
|
|
973
|
+
|
|
668
974
|
if (options['install-codex']) {
|
|
669
975
|
installCodexSkill({ env: context.env, stdout: context.stdout })
|
|
670
976
|
return 0
|
|
@@ -681,7 +987,7 @@ const printHelp = stdout => {
|
|
|
681
987
|
toast review-ci status --repo ORG/REPO --pr NUMBER --head SHA
|
|
682
988
|
toast review-ci next --repo ORG/REPO --pr NUMBER --head SHA
|
|
683
989
|
toast review-ci wait --repo ORG/REPO --pr NUMBER --head SHA
|
|
684
|
-
toast review-ci instructions [--install-codex]
|
|
990
|
+
toast review-ci instructions [--install|--install-agent|--install-codex]
|
|
685
991
|
`)
|
|
686
992
|
}
|
|
687
993
|
|
|
@@ -707,6 +1013,7 @@ const main = async ({
|
|
|
707
1013
|
createBrowserCallbackServer = createBrowserCallbackServerDefault,
|
|
708
1014
|
openBrowser = openBrowserDefault,
|
|
709
1015
|
getGitRemoteUrl = getGitRemoteUrlDefault,
|
|
1016
|
+
cwd = process.cwd(),
|
|
710
1017
|
stdout = process.stdout,
|
|
711
1018
|
stderr = process.stderr,
|
|
712
1019
|
}) => {
|
|
@@ -721,6 +1028,7 @@ const main = async ({
|
|
|
721
1028
|
createBrowserCallbackServer,
|
|
722
1029
|
openBrowser,
|
|
723
1030
|
getGitRemoteUrl,
|
|
1031
|
+
cwd,
|
|
724
1032
|
stdout,
|
|
725
1033
|
stderr,
|
|
726
1034
|
}
|