@rtorcato/repo-tooling 3.21.1 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/base/fixers.js
CHANGED
|
@@ -366,7 +366,11 @@ export const BASE_FIXERS = [
|
|
|
366
366
|
for (const name of SHIPPED_SKILLS) {
|
|
367
367
|
const result = await installClaudeSkill(dir, name, { force: forceSkills });
|
|
368
368
|
if (result.status === 'declined-downgrade') {
|
|
369
|
-
|
|
369
|
+
// Say what was compared, not what is newer: the version is a label,
|
|
370
|
+
// and on a git checkout it can understate the content behind it
|
|
371
|
+
// (#522). Naming the escape hatch matters for exactly that case.
|
|
372
|
+
console.error(chalk.yellow(` skipped — ${result.file} is stamped ${result.installedVersion}, above the ${result.shippedVersion} this package reports; not overwritten`));
|
|
373
|
+
console.error(chalk.yellow(' overwrite anyway: fix claude-skills --force-skills'));
|
|
370
374
|
continue;
|
|
371
375
|
}
|
|
372
376
|
if (result.status === 'declined-fork') {
|
|
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import fs from 'fs-extra';
|
|
11
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
11
12
|
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
12
13
|
import { shellQuote } from '../utils/shell.js';
|
|
13
14
|
/**
|
|
@@ -127,13 +128,41 @@ export function isNewerVersion(a, b) {
|
|
|
127
128
|
}
|
|
128
129
|
return false;
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The version to stamp, given what `package.json` claims.
|
|
133
|
+
*
|
|
134
|
+
* In a published tarball that field is authoritative — `@semantic-release/npm`
|
|
135
|
+
* rewrites it before packing. In a *git checkout* it is not: this repo runs
|
|
136
|
+
* semantic-release without `@semantic-release/git` (#417), so nothing ever
|
|
137
|
+
* writes the released version back and the field sits at whatever it was last
|
|
138
|
+
* hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
|
|
139
|
+
* ten minor versions of drift.
|
|
140
|
+
*
|
|
141
|
+
* That mattered because the stamp feeds the downgrade guard below: a skill
|
|
142
|
+
* installed from npm could not be updated from a local checkout, and the
|
|
143
|
+
* refusal claimed the installed copy was "newer" when only its *label* was.
|
|
144
|
+
*
|
|
145
|
+
* So when the package root is a git checkout, take the nearest tag as well and
|
|
146
|
+
* keep whichever is higher. Monotonic on purpose — this can only ever raise the
|
|
147
|
+
* answer, so a tagless, shallow, or git-less environment keeps today's
|
|
148
|
+
* behaviour rather than silently stamping something lower.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
|
|
151
|
+
if (!(await fs.pathExists(path.join(root, '.git'))))
|
|
152
|
+
return pkgVersion;
|
|
153
|
+
const described = await git(['describe', '--tags', '--abbrev=0'], root);
|
|
154
|
+
const tag = described?.trim().replace(/^v/, '');
|
|
155
|
+
if (!tag || !/^\d+\.\d+/.test(tag))
|
|
156
|
+
return pkgVersion;
|
|
157
|
+
return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
|
|
158
|
+
}
|
|
130
159
|
/** The skill source and the package version that will be stamped into it. */
|
|
131
|
-
export async function readShippedSkill(name = SHIPPED_SKILL) {
|
|
160
|
+
export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
|
|
132
161
|
const root = getPackageRoot();
|
|
133
162
|
const file = path.join(root, 'skills', name, 'SKILL.md');
|
|
134
163
|
const content = await fs.readFile(file, 'utf8');
|
|
135
164
|
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
136
|
-
return { content, version: String(pkg.version), file };
|
|
165
|
+
return { content, version: await resolveShippedVersion(root, String(pkg.version), git), file };
|
|
137
166
|
}
|
|
138
167
|
/**
|
|
139
168
|
* The command a human runs to see what their fork changed, before deciding
|
|
@@ -187,7 +216,12 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { forc
|
|
|
187
216
|
installedVersion,
|
|
188
217
|
shippedVersion: shipped.version,
|
|
189
218
|
};
|
|
190
|
-
|
|
219
|
+
// `force` overrides this too, not just the fork check below. It already
|
|
220
|
+
// overrides the *stronger* protection — overwriting content someone
|
|
221
|
+
// deliberately edited — so refusing on a version comparison while allowing
|
|
222
|
+
// that was backwards, and left no escape hatch at all when the comparison
|
|
223
|
+
// was wrong (#522).
|
|
224
|
+
if (!force && installedVersion && isNewerVersion(installedVersion, shipped.version)) {
|
|
191
225
|
return { ...base, status: 'declined-downgrade' };
|
|
192
226
|
}
|
|
193
227
|
// Only `pristine` content is provably ours to replace. Anything else is a
|
|
@@ -66,13 +66,18 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
66
66
|
if (!valid) {
|
|
67
67
|
throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
// One read, because everything not rebuilt from `config` has to be carried
|
|
70
|
+
// forward explicitly — this object is constructed from scratch, so any key
|
|
71
|
+
// not named here is dropped by the next `fix lockfile`.
|
|
72
|
+
const existing = await readLockfile(dir);
|
|
73
|
+
const carried = assets ?? existing?.assets;
|
|
70
74
|
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
71
75
|
const lockfile = {
|
|
72
76
|
$schema: LOCKFILE_SCHEMA_URL,
|
|
73
77
|
version: LOCKFILE_VERSION,
|
|
74
78
|
config,
|
|
75
79
|
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
80
|
+
...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
|
|
76
81
|
writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
|
|
77
82
|
writtenAt: new Date().toISOString(),
|
|
78
83
|
};
|
package/package.json
CHANGED
|
@@ -207,6 +207,54 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
|
|
|
207
207
|
inside one it did not choose. `--git-common-dir` resolves to the main checkout's
|
|
208
208
|
`.git` from anywhere, including a worktree, so `ROOT` is correct either way.
|
|
209
209
|
|
|
210
|
+
**Resolve `AGENT_USER` — the account in-flight work is assigned to.** Optional:
|
|
211
|
+
unset, every step below that would assign it simply does nothing, and assignment
|
|
212
|
+
behaves exactly as it did before this existed.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
# Repo config first — committed, so it travels with the repo and survives a new
|
|
216
|
+
# machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile.
|
|
217
|
+
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
218
|
+
# A typo would fail every `gh` edit for the whole tick, so prove it is assignable
|
|
219
|
+
# once, here. 204 = yes, 404 = no; push access is what qualifies an account.
|
|
220
|
+
[ -n "$AGENT_USER" ] && { gh api "repos/$OWNER_REPO/assignees/$AGENT_USER" --silent 2>/dev/null || {
|
|
221
|
+
echo "⚠ agentUser '$AGENT_USER' is not an assignable collaborator — assigning nothing"
|
|
222
|
+
AGENT_USER=""; }; }
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**It lives in the repo, not a shell profile.** The agent account is a
|
|
226
|
+
collaborator on *this* repo, so a machine-wide env var is both the wrong
|
|
227
|
+
granularity and invisible — forgotten on a new laptop, with the only symptom
|
|
228
|
+
being that assignment quietly stops. In `.repo-tooling.json` it is committed,
|
|
229
|
+
reviewable, and carried forward by `fix lockfile`:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{ "aiLoop": { "agentUser": "your-bot-account" } }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Every later use is `${AGENT_USER:+--add-assignee "$AGENT_USER"}`, which expands
|
|
236
|
+
to nothing when it is empty — so there is one code path, not two.
|
|
237
|
+
|
|
238
|
+
**The point is that assignee answers "whose turn is it", which no label does
|
|
239
|
+
well.** Today an issue an agent is mid-way through and an issue nobody has
|
|
240
|
+
touched are both assigned to no one, so the *Assigned to you* view is only ever
|
|
241
|
+
half the story:
|
|
242
|
+
|
|
243
|
+
| State | Assignee |
|
|
244
|
+
|---|---|
|
|
245
|
+
| issue `ai-ready`, unclaimed | nobody |
|
|
246
|
+
| issue `ai-wip` — an agent is implementing it | `AGENT_USER` |
|
|
247
|
+
| PR `ai-review` / `ai-changes` — an agent is reviewing or fixing | `AGENT_USER` |
|
|
248
|
+
| PR passed both reviews, waiting to merge | the human |
|
|
249
|
+
| `ai-blocked`, declined, or held | the human |
|
|
250
|
+
|
|
251
|
+
`@me` cannot express this: it resolves to whichever token is running, and the
|
|
252
|
+
agents authenticate as the owner, so `@me` is *always* the human. That is why
|
|
253
|
+
this is a separate name rather than a reuse.
|
|
254
|
+
|
|
255
|
+
Note the web UI's assignee picker can show a stale list that omits a
|
|
256
|
+
freshly-added collaborator; `repos/{repo}/assignees` is the authority.
|
|
257
|
+
|
|
210
258
|
**Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
|
|
211
259
|
`core.bare = true` on its own, repeatedly — four times in one session, some occurrences
|
|
212
260
|
immediately after a `worktree remove` and some with nothing removed at all. The trigger
|
|
@@ -389,9 +437,13 @@ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec
|
|
|
389
437
|
not `ai-changes`, assign it and clear the stale review flag:
|
|
390
438
|
|
|
391
439
|
```bash
|
|
392
|
-
gh pr edit <N> --add-assignee @me --remove-label ai-review
|
|
440
|
+
gh pr edit <N> --add-assignee @me --remove-label ai-review \
|
|
441
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
393
442
|
```
|
|
394
443
|
|
|
444
|
+
Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
|
|
445
|
+
you says you both owe it something, which is the one thing never true here.
|
|
446
|
+
|
|
395
447
|
It lands in the user's *Assigned to you* view, and the labels then read as state rather
|
|
396
448
|
than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on you**. Both
|
|
397
449
|
halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
|
|
@@ -451,7 +503,7 @@ no other branch of this pass assigns it, which leaves it in no *Assigned to you*
|
|
|
451
503
|
view at all:
|
|
452
504
|
|
|
453
505
|
```bash
|
|
454
|
-
gh pr edit <N> --add-assignee @me
|
|
506
|
+
gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
455
507
|
```
|
|
456
508
|
|
|
457
509
|
Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
|
|
@@ -536,7 +588,7 @@ Only then:
|
|
|
536
588
|
REMOVED=1 # every removal in this pass sets this
|
|
537
589
|
git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
|
|
538
590
|
git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
|
|
539
|
-
gh issue edit <N> --remove-label ai-wip 2>/dev/null
|
|
591
|
+
gh issue edit <N> --remove-label ai-wip ${AGENT_USER:+--remove-assignee "$AGENT_USER"} 2>/dev/null
|
|
540
592
|
# Still OPEN means the PR said only `Refs #N`; a `Closes #N` issue is already closed.
|
|
541
593
|
if [ "$(gh issue view <N> --json state -q .state)" = OPEN ]; then
|
|
542
594
|
gh issue edit <N> --add-assignee @me
|
|
@@ -570,7 +622,7 @@ work must never be reaped out from under itself.
|
|
|
570
622
|
|
|
571
623
|
| Stalled | Condition | Do |
|
|
572
624
|
|---|---|---|
|
|
573
|
-
| Implementer died | issue `ai-wip` ≥45min, **and no PR exists** for `ai-<N>-<slug>` | `gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me`, comment, remove the worktree (and set `REMOVED=1`) |
|
|
625
|
+
| Implementer died | issue `ai-wip` ≥45min, **and no PR exists** for `ai-<N>-<slug>` | `gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}`, comment, remove the worktree (and set `REMOVED=1`) |
|
|
574
626
|
| Reviewer died | PR `ai-reviewing-code` (or `ai-reviewing-sec`) ≥45min with no matching `ai-ok-*` and no `ai-changes` | `gh pr edit <N> --remove-label <the claim that stalled>` — drop **that** label, not a fixed one; a stalled `ai-reviewing-sec` cleared as `ai-reviewing-code` leaves the dead claim in place and the reviewer never re-spawns. Dropping the claim is what lets Pass 3 re-spawn it, and they're cheap and diff-scoped. If that claim has been applied ≥3 times, `ai-blocked` instead |
|
|
575
627
|
| Orphan worktree | `"$WT_ROOT"/ai-<N>-*` whose issue is not `ai-wip` and has no open PR | remove the worktree and branch (and set `REMOVED=1`) |
|
|
576
628
|
|
|
@@ -755,10 +807,13 @@ intended; one that died after posting is now recovered instead of duplicated.
|
|
|
755
807
|
issue. Apply the label immediately before the spawn, not after:
|
|
756
808
|
|
|
757
809
|
```bash
|
|
758
|
-
gh pr edit <N> --add-label ai-reviewing-code # then spawn code-reviewer
|
|
759
|
-
gh pr edit <N> --add-label ai-reviewing-sec
|
|
810
|
+
gh pr edit <N> --add-label ai-reviewing-code ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn code-reviewer
|
|
811
|
+
gh pr edit <N> --add-label ai-reviewing-sec ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn security-expert
|
|
760
812
|
```
|
|
761
813
|
|
|
814
|
+
Assigning `AGENT_USER` on the claim is idempotent — both arms adding the same
|
|
815
|
+
account is one assignee, and Pass 1 removes it at the handoff.
|
|
816
|
+
|
|
762
817
|
Without the claim there is no window in which "a reviewer is running" is visible.
|
|
763
818
|
A reviewer applies its verdict label only at the *end*, after reading the diff and
|
|
764
819
|
posting its comment, so from spawn until then the labels are indistinguishable
|
|
@@ -1016,8 +1071,10 @@ and a blank line — naming what each round changed and why the reviewer kept ob
|
|
|
1016
1071
|
then:
|
|
1017
1072
|
|
|
1018
1073
|
```bash
|
|
1019
|
-
gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
|
|
1020
|
-
|
|
1074
|
+
gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
|
|
1075
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
1076
|
+
gh pr edit <N> --add-assignee @me --remove-label ai-review \
|
|
1077
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
1021
1078
|
```
|
|
1022
1079
|
|
|
1023
1080
|
Leave the worktree and PR in place for the human; a ping-pong stall is the case where
|
|
@@ -1135,9 +1192,13 @@ Take the first `slots` issues. For each, **claim it first** so a concurrent tick
|
|
|
1135
1192
|
can't double-pick:
|
|
1136
1193
|
|
|
1137
1194
|
```bash
|
|
1138
|
-
gh issue edit <N> --add-label ai-wip --remove-label ai-ready
|
|
1195
|
+
gh issue edit <N> --add-label ai-wip --remove-label ai-ready \
|
|
1196
|
+
${AGENT_USER:+--add-assignee "$AGENT_USER"}
|
|
1139
1197
|
```
|
|
1140
1198
|
|
|
1199
|
+
Assigning here is what makes the issue list honest: from this moment an agent
|
|
1200
|
+
owns the work, and an unassigned `ai-ready` issue is genuinely untouched.
|
|
1201
|
+
|
|
1141
1202
|
Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
|
|
1142
1203
|
transition, not an accumulation. An issue left carrying both re-enters the queue
|
|
1143
1204
|
the instant `ai-wip` clears for any reason other than the PR closing it, and the
|
|
@@ -1274,9 +1335,13 @@ Then spawn a background implementer agent:
|
|
|
1274
1335
|
> If you cannot finish, hand it back so a human can see it:
|
|
1275
1336
|
>
|
|
1276
1337
|
> ```bash
|
|
1277
|
-
> gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
|
|
1338
|
+
> gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
|
|
1339
|
+
> <the orchestrator substitutes `--remove-assignee <AGENT_USER>` here, or nothing>
|
|
1278
1340
|
> ```
|
|
1279
1341
|
>
|
|
1342
|
+
> Handing back means the issue stops being the agent's: the human must end up the
|
|
1343
|
+
> only assignee, or the list still reads as though something is working on it.
|
|
1344
|
+
>
|
|
1280
1345
|
> Then comment why. **Leave your worktree in place — never run
|
|
1281
1346
|
> `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
|
|
1282
1347
|
> longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
|
|
@@ -42,6 +42,12 @@ argument.
|
|
|
42
42
|
Filter the PR list to those carrying an `ai-*` label — a PR without one is
|
|
43
43
|
not in the pipeline and the loop will never touch it.
|
|
44
44
|
|
|
45
|
+
**Read the assignee as "whose turn"**, when the loop is configured with an
|
|
46
|
+
agent account (`AI_LOOP_AGENT`): that account assigned means an agent is
|
|
47
|
+
working or reviewing, the human assigned means it is waiting on them, and
|
|
48
|
+
nobody assigned means queued. Say which in the report rather than listing raw
|
|
49
|
+
logins — "waiting on you" beats "assignee: someone".
|
|
50
|
+
|
|
45
51
|
3. **Work out each PR's next move** from its labels, so the report says what
|
|
46
52
|
happens rather than just listing state:
|
|
47
53
|
|
|
@@ -47,6 +47,12 @@ ROOT=$(git rev-parse --path-format=absolute --git-common-dir)/..; ROOT=$(cd "$RO
|
|
|
47
47
|
WT_ROOT="$(dirname "$ROOT")/$(basename "$ROOT")-worktrees"
|
|
48
48
|
R=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
49
49
|
git -C "$ROOT" fetch --prune
|
|
50
|
+
|
|
51
|
+
# Optional: the account in-flight work is assigned to, so `assignee` says whose
|
|
52
|
+
# turn it is. Unset → nothing below assigns, exactly as before. See the
|
|
53
|
+
# ai-issue-loop skill's Pass 0 for why this is repo config rather than an env var.
|
|
54
|
+
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
55
|
+
[ -n "$AGENT_USER" ] && { gh api "repos/$R/assignees/$AGENT_USER" --silent 2>/dev/null || AGENT_USER=""; }
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
`R` comes from the working directory's remote and is the only repo touched —
|
|
@@ -112,7 +118,8 @@ carrying both re-enters the queue the instant `ai-wip` clears):
|
|
|
112
118
|
|
|
113
119
|
```bash
|
|
114
120
|
for n in <numbers>; do
|
|
115
|
-
gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready
|
|
121
|
+
gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready \
|
|
122
|
+
${AGENT_USER:+--add-assignee "$AGENT_USER"}
|
|
116
123
|
SLUG="ai-$n-<3-4 kebab words from the title>"
|
|
117
124
|
mkdir -p "$WT_ROOT"
|
|
118
125
|
git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
|
|
@@ -133,9 +140,12 @@ and why.
|
|
|
133
140
|
Call `Workflow` with the script below, passing the selected issues as `args`:
|
|
134
141
|
|
|
135
142
|
```
|
|
136
|
-
Workflow({args: {repo: R, issues: [{number, title, slug, worktree}, …]}, script: …})
|
|
143
|
+
Workflow({args: {repo: R, agentUser: AGENT_USER, issues: [{number, title, slug, worktree}, …]}, script: …})
|
|
137
144
|
```
|
|
138
145
|
|
|
146
|
+
Pass `agentUser` as the empty string when `AGENT_USER` is unset — the script
|
|
147
|
+
tests it, so an empty value simply drops every assign.
|
|
148
|
+
|
|
139
149
|
```js
|
|
140
150
|
export const meta = {
|
|
141
151
|
name: 'ai-workflow',
|
|
@@ -205,8 +215,9 @@ leave the worktree in place, and return pr: null.`,
|
|
|
205
215
|
|
|
206
216
|
(r, i) => !r?.pr ? [] : parallel(REVIEWERS.map((v) => () => agent(
|
|
207
217
|
`Review GitHub PR #${r.pr} in ${args.repo}. First claim your arm:
|
|
208
|
-
\`gh pr edit ${r.pr} --add-label ${v.claim}\` —
|
|
209
|
-
ai-issue-loop tick spawning a duplicate of you
|
|
218
|
+
\`gh pr edit ${r.pr} --add-label ${v.claim}${args.agentUser ? ` --add-assignee ${args.agentUser}` : ''}\` — the label
|
|
219
|
+
stops a concurrent ai-issue-loop tick spawning a duplicate of you, and the
|
|
220
|
+
assignee says the PR is the machine's turn until Pass 1 hands it back.
|
|
210
221
|
|
|
211
222
|
Read exactly three things and nothing else: \`gh pr view ${r.pr}\`,
|
|
212
223
|
\`gh pr diff ${r.pr}\`, and \`gh issue view ${i.number}\`. Do not explore the
|