rl-workspaces 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +703 -2
- package/bin/rl +54 -0
- package/config.example.zsh +9 -0
- package/dist/access.js +40 -0
- package/dist/activity.js +90 -0
- package/dist/agent-claude.js +16 -0
- package/dist/agent-codex.js +22 -0
- package/dist/agent-launch.js +74 -0
- package/dist/agent-presets.js +45 -0
- package/dist/agent-registry.js +8 -0
- package/dist/agent-resume.js +96 -0
- package/dist/agent-runner.js +366 -0
- package/dist/agent-workflows.js +231 -0
- package/dist/agents.js +73 -0
- package/dist/automation-config.js +242 -0
- package/dist/base.js +66 -0
- package/dist/cleanup-path.js +33 -0
- package/dist/cli.js +57 -0
- package/dist/codex-sessions.js +188 -0
- package/dist/command-context.js +67 -0
- package/dist/common.js +262 -0
- package/dist/delete.js +113 -0
- package/dist/display.js +134 -0
- package/dist/doctor.js +145 -0
- package/dist/git-safety.js +24 -0
- package/dist/git-workflow.js +183 -0
- package/dist/help.js +726 -0
- package/dist/hooks.js +159 -0
- package/dist/json.js +34 -0
- package/dist/mcp.js +336 -0
- package/dist/picker.js +182 -0
- package/dist/pr-discovery.js +223 -0
- package/dist/pr-update.js +419 -0
- package/dist/pr-workflow.js +161 -0
- package/dist/repos.js +537 -0
- package/dist/review-policy.js +150 -0
- package/dist/session-workflows.js +616 -0
- package/dist/session.js +12 -0
- package/dist/state.js +250 -0
- package/dist/storage.js +366 -0
- package/dist/store.js +379 -0
- package/dist/summary.js +164 -0
- package/dist/task-state.js +65 -0
- package/dist/task.js +192 -0
- package/dist/templates.js +4 -0
- package/dist/workflow-config.js +177 -0
- package/dist/workflow.js +211 -0
- package/lib/rl.zsh +330 -0
- package/node_modules/graceful-fs/LICENSE +15 -0
- package/node_modules/graceful-fs/README.md +143 -0
- package/node_modules/graceful-fs/clone.js +23 -0
- package/node_modules/graceful-fs/graceful-fs.js +448 -0
- package/node_modules/graceful-fs/legacy-streams.js +118 -0
- package/node_modules/graceful-fs/package.json +53 -0
- package/node_modules/graceful-fs/polyfills.js +355 -0
- package/node_modules/proper-lockfile/LICENSE +21 -0
- package/node_modules/proper-lockfile/README.md +183 -0
- package/node_modules/proper-lockfile/index.js +40 -0
- package/node_modules/proper-lockfile/lib/adapter.js +85 -0
- package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
- package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
- package/node_modules/proper-lockfile/package.json +71 -0
- package/node_modules/retry/.npmignore +3 -0
- package/node_modules/retry/.travis.yml +15 -0
- package/node_modules/retry/License +21 -0
- package/node_modules/retry/Makefile +18 -0
- package/node_modules/retry/README.md +227 -0
- package/node_modules/retry/equation.gif +0 -0
- package/node_modules/retry/example/dns.js +31 -0
- package/node_modules/retry/example/stop.js +40 -0
- package/node_modules/retry/index.js +1 -0
- package/node_modules/retry/lib/retry.js +100 -0
- package/node_modules/retry/lib/retry_operation.js +158 -0
- package/node_modules/retry/package.json +32 -0
- package/node_modules/retry/test/common.js +10 -0
- package/node_modules/retry/test/integration/test-forever.js +24 -0
- package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
- package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
- package/node_modules/retry/test/integration/test-timeouts.js +69 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +39 -0
- package/node_modules/signal-exit/index.js +202 -0
- package/node_modules/signal-exit/package.json +38 -0
- package/node_modules/signal-exit/signals.js +53 -0
- package/package.json +57 -4
- package/shell/rl.zsh +11 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { args, arity, execute, git, json, message, object, pooled, shellQuote, } from "./common.js";
|
|
3
|
+
import { commandContext, contextOptions } from "./command-context.js";
|
|
4
|
+
import { github, repositoryIdentity } from "./pr-workflow.js";
|
|
5
|
+
import { parseJson, parseJsonObject } from "./json.js";
|
|
6
|
+
const FIELDS = "number,url,title,state,headRefName,headRefOid,baseRefName,baseRefOid,isCrossRepository,headRepository,headRepositoryOwner,mergeable,isDraft";
|
|
7
|
+
const HASH = /^[a-f0-9]{40,64}$/;
|
|
8
|
+
const visible = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
9
|
+
function validatePr(raw, number, repository) {
|
|
10
|
+
if (!object(raw) ||
|
|
11
|
+
raw.number !== number ||
|
|
12
|
+
raw.url !== `${repository.url}/pull/${number}` ||
|
|
13
|
+
typeof raw.title !== "string" ||
|
|
14
|
+
typeof raw.state !== "string" ||
|
|
15
|
+
typeof raw.headRefName !== "string" ||
|
|
16
|
+
typeof raw.baseRefName !== "string" ||
|
|
17
|
+
typeof raw.headRefOid !== "string" ||
|
|
18
|
+
!HASH.test(raw.headRefOid) ||
|
|
19
|
+
typeof raw.baseRefOid !== "string" ||
|
|
20
|
+
!HASH.test(raw.baseRefOid) ||
|
|
21
|
+
typeof raw.isCrossRepository !== "boolean" ||
|
|
22
|
+
typeof raw.mergeable !== "string" ||
|
|
23
|
+
typeof raw.isDraft !== "boolean" ||
|
|
24
|
+
!object(raw.headRepository) ||
|
|
25
|
+
typeof raw.headRepository.name !== "string" ||
|
|
26
|
+
!object(raw.headRepositoryOwner) ||
|
|
27
|
+
typeof raw.headRepositoryOwner.login !== "string")
|
|
28
|
+
throw new Error(`Invalid metadata for PR #${number}`);
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
function apiError(stdout, stderr) {
|
|
32
|
+
try {
|
|
33
|
+
const response = parseJsonObject(stdout);
|
|
34
|
+
if (typeof response.message === "string")
|
|
35
|
+
return response.message;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* gh may report a transport error without a JSON response. */
|
|
39
|
+
}
|
|
40
|
+
return stderr.trim() || "GitHub request failed";
|
|
41
|
+
}
|
|
42
|
+
function errorStatus(error) {
|
|
43
|
+
if (/\bconflicts?\b/i.test(error))
|
|
44
|
+
return "conflict";
|
|
45
|
+
if (/(?:head|sha).*(?:changed|modified|match|expected)|(?:changed|modified).*(?:head|sha)/i.test(error))
|
|
46
|
+
return "changed";
|
|
47
|
+
return "failed";
|
|
48
|
+
}
|
|
49
|
+
async function choose(rows, cwd, base, dryRun) {
|
|
50
|
+
const choices = rows.filter((row) => (row.behindCommits ?? 0) > 0 &&
|
|
51
|
+
["ready", "conflict"].includes(row.status));
|
|
52
|
+
if (!choices.length)
|
|
53
|
+
return [];
|
|
54
|
+
const lines = choices.map((row) => `${row.number}\t#${row.number} ${row.behindCommits} commits behind ${visible(row.branch)} ${row.status === "conflict" ? "[conflicts] " : ""}${visible(row.title)}`);
|
|
55
|
+
let selected;
|
|
56
|
+
try {
|
|
57
|
+
selected = await execute([
|
|
58
|
+
"fzf",
|
|
59
|
+
"--multi",
|
|
60
|
+
"--delimiter=\t",
|
|
61
|
+
"--with-nth=2..",
|
|
62
|
+
"--no-sort",
|
|
63
|
+
"--height=70%",
|
|
64
|
+
"--border=rounded",
|
|
65
|
+
"--reverse",
|
|
66
|
+
"--prompt=Update PRs › ",
|
|
67
|
+
"--bind=ctrl-a:select-all,ctrl-d:deselect-all",
|
|
68
|
+
`--header=Your active RL session PRs · Tab select · Enter ${dryRun ? "preview" : "merge base into selected PR branches"} · Esc cancel\nBase: ${visible(base)} · behind counts are commits`,
|
|
69
|
+
], cwd, { input: lines.join("\n") + "\n", terminalError: true });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new Error(`Cannot open PR selector: ${message(error)}. Install fzf or pass PR numbers explicitly`);
|
|
73
|
+
}
|
|
74
|
+
if ([1, 130].includes(selected.code))
|
|
75
|
+
return [];
|
|
76
|
+
if (selected.code)
|
|
77
|
+
throw new Error(`PR selector failed (exit ${selected.code})`);
|
|
78
|
+
const ids = selected.stdout
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((line) => {
|
|
82
|
+
const match = lines.find((choice) => choice === line || choice.split("\t")[0] === line);
|
|
83
|
+
if (!match)
|
|
84
|
+
throw new Error("PR selector returned an unknown selection");
|
|
85
|
+
return Number(match.split("\t")[0]);
|
|
86
|
+
});
|
|
87
|
+
return [...new Set(ids)];
|
|
88
|
+
}
|
|
89
|
+
export async function main(argv) {
|
|
90
|
+
const { values, positionals } = args(argv, [...contextOptions, "base", "limit"], ["json", "dry-run"]);
|
|
91
|
+
const [command, action, ...requested] = positionals;
|
|
92
|
+
if (command !== "pr" || action !== "update")
|
|
93
|
+
throw new Error("Usage: rl pr update [PR numbers...] [--base test] [--dry-run] [--json]");
|
|
94
|
+
arity(requested, 0, 50);
|
|
95
|
+
if (requested.some((number) => !/^[1-9]\d*$/.test(number) || !Number.isSafeInteger(Number(number))))
|
|
96
|
+
throw new Error("Expected positive PR numbers");
|
|
97
|
+
const context = commandContext(values);
|
|
98
|
+
const [file, data] = context.store.current();
|
|
99
|
+
// Session ownership comes from repository-scoped RL records, never PR authors
|
|
100
|
+
// or a cached PR number. Keep the original owners so replacement sessions do
|
|
101
|
+
// not silently authorize an already selected update.
|
|
102
|
+
const owners = new Map();
|
|
103
|
+
for (const [, instance] of context.store.records()) {
|
|
104
|
+
if (instance.status !== "active")
|
|
105
|
+
continue;
|
|
106
|
+
const keys = owners.get(instance.branch) ?? new Set();
|
|
107
|
+
keys.add(instance.key);
|
|
108
|
+
owners.set(instance.branch, keys);
|
|
109
|
+
}
|
|
110
|
+
const ownsBranch = (branch) => {
|
|
111
|
+
const keys = owners.get(branch);
|
|
112
|
+
return (!!keys &&
|
|
113
|
+
context.store
|
|
114
|
+
.records()
|
|
115
|
+
.some(([, instance]) => instance.status === "active" &&
|
|
116
|
+
instance.branch === branch &&
|
|
117
|
+
keys.has(instance.key)));
|
|
118
|
+
};
|
|
119
|
+
const base = String(values.base ?? context.defaultBase).replace(/^origin\//, "");
|
|
120
|
+
if (base.startsWith("-") ||
|
|
121
|
+
git(data.worktree, "check-ref-format", "--branch", base) !== base)
|
|
122
|
+
throw new Error("Expected a literal base branch name");
|
|
123
|
+
const limit = Number(values.limit ?? 100);
|
|
124
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
125
|
+
throw new Error("--limit must be between 1 and 200");
|
|
126
|
+
const repository = await repositoryIdentity(data.worktree);
|
|
127
|
+
const hostname = new URL(repository.url).host;
|
|
128
|
+
const endpoint = `repos/${repository.name}`;
|
|
129
|
+
const recheck = () => {
|
|
130
|
+
const [currentFile, current] = context.store.current();
|
|
131
|
+
if (currentFile !== file ||
|
|
132
|
+
current.key !== data.key ||
|
|
133
|
+
current.worktree !== data.worktree)
|
|
134
|
+
throw new Error("Current RL workspace changed during PR updates");
|
|
135
|
+
};
|
|
136
|
+
async function getPr(number) {
|
|
137
|
+
const result = await github(data.worktree, [
|
|
138
|
+
"pr",
|
|
139
|
+
"view",
|
|
140
|
+
String(number),
|
|
141
|
+
"--repo",
|
|
142
|
+
repository.url,
|
|
143
|
+
"--json",
|
|
144
|
+
FIELDS,
|
|
145
|
+
]);
|
|
146
|
+
if (result.code)
|
|
147
|
+
throw new Error(apiError(result.stdout, result.stderr));
|
|
148
|
+
const pr = validatePr(parseJsonObject(result.stdout), number, repository);
|
|
149
|
+
git(data.worktree, "check-ref-format", "--branch", pr.headRefName);
|
|
150
|
+
git(data.worktree, "check-ref-format", "--branch", pr.baseRefName);
|
|
151
|
+
return pr;
|
|
152
|
+
}
|
|
153
|
+
async function inspect(number) {
|
|
154
|
+
try {
|
|
155
|
+
recheck();
|
|
156
|
+
const pr = await getPr(number);
|
|
157
|
+
const row = {
|
|
158
|
+
number,
|
|
159
|
+
url: pr.url,
|
|
160
|
+
branch: pr.headRefName,
|
|
161
|
+
title: pr.title,
|
|
162
|
+
status: "ready",
|
|
163
|
+
headBefore: pr.headRefOid,
|
|
164
|
+
baseCommit: pr.baseRefOid,
|
|
165
|
+
mergeability: pr.mergeable,
|
|
166
|
+
detail: "",
|
|
167
|
+
};
|
|
168
|
+
if (!ownsBranch(pr.headRefName))
|
|
169
|
+
return {
|
|
170
|
+
...row,
|
|
171
|
+
status: "skipped",
|
|
172
|
+
detail: "PR branch does not belong to an active RL session in this repository",
|
|
173
|
+
};
|
|
174
|
+
if (pr.state !== "OPEN" ||
|
|
175
|
+
pr.baseRefName !== base ||
|
|
176
|
+
pr.headRefName === base ||
|
|
177
|
+
pr.isCrossRepository ||
|
|
178
|
+
`${pr.headRepositoryOwner.login}/${pr.headRepository.name}`.toLowerCase() !==
|
|
179
|
+
repository.name.toLowerCase())
|
|
180
|
+
return {
|
|
181
|
+
...row,
|
|
182
|
+
status: "skipped",
|
|
183
|
+
detail: `Requires an open PR in this repository targeting ${base}; fork PRs are not updated`,
|
|
184
|
+
};
|
|
185
|
+
const compared = await github(data.worktree, [
|
|
186
|
+
"api",
|
|
187
|
+
"--hostname",
|
|
188
|
+
hostname,
|
|
189
|
+
"--method",
|
|
190
|
+
"GET",
|
|
191
|
+
`${endpoint}/compare/${pr.baseRefOid}...${pr.headRefOid}?per_page=1`,
|
|
192
|
+
]);
|
|
193
|
+
if (compared.code)
|
|
194
|
+
throw new Error(apiError(compared.stdout, compared.stderr));
|
|
195
|
+
const comparison = parseJsonObject(compared.stdout);
|
|
196
|
+
if (typeof comparison.behind_by !== "number" ||
|
|
197
|
+
!Number.isSafeInteger(comparison.behind_by) ||
|
|
198
|
+
comparison.behind_by < 0)
|
|
199
|
+
throw new Error("Invalid commit comparison from GitHub");
|
|
200
|
+
row.behindCommits = comparison.behind_by;
|
|
201
|
+
if (!row.behindCommits)
|
|
202
|
+
return {
|
|
203
|
+
...row,
|
|
204
|
+
status: "up-to-date",
|
|
205
|
+
detail: `Already contains ${base}`,
|
|
206
|
+
};
|
|
207
|
+
if (pr.mergeable === "CONFLICTING")
|
|
208
|
+
return {
|
|
209
|
+
...row,
|
|
210
|
+
status: "conflict",
|
|
211
|
+
detail: "GitHub reports merge conflicts; resolve this PR separately",
|
|
212
|
+
};
|
|
213
|
+
return { ...row, detail: `${row.behindCommits} commits behind ${base}` };
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
return { number, status: "failed", detail: message(error) };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
let selected = [...new Set(requested.map(Number))];
|
|
220
|
+
const initial = new Map();
|
|
221
|
+
if (!selected.length) {
|
|
222
|
+
if (!values.json)
|
|
223
|
+
console.error(`Checking up to ${limit} open PRs from your active RL sessions targeting ${base}…`);
|
|
224
|
+
const branches = [...owners.keys()].sort();
|
|
225
|
+
const byBranch = new Map();
|
|
226
|
+
await pooled(branches, 4, async (branch) => {
|
|
227
|
+
const listed = await github(data.worktree, [
|
|
228
|
+
"pr",
|
|
229
|
+
"list",
|
|
230
|
+
"--repo",
|
|
231
|
+
repository.url,
|
|
232
|
+
"--head",
|
|
233
|
+
branch,
|
|
234
|
+
"--base",
|
|
235
|
+
base,
|
|
236
|
+
"--state",
|
|
237
|
+
"open",
|
|
238
|
+
"--limit",
|
|
239
|
+
String(limit),
|
|
240
|
+
"--json",
|
|
241
|
+
"number,headRefName",
|
|
242
|
+
]);
|
|
243
|
+
if (listed.code)
|
|
244
|
+
throw new Error(apiError(listed.stdout, listed.stderr));
|
|
245
|
+
const list = parseJson(listed.stdout);
|
|
246
|
+
if (!Array.isArray(list) ||
|
|
247
|
+
list.some((item) => !object(item) ||
|
|
248
|
+
!Number.isSafeInteger(item.number) ||
|
|
249
|
+
Number(item.number) < 1 ||
|
|
250
|
+
item.headRefName !== branch))
|
|
251
|
+
throw new Error("Invalid RL session PR list from GitHub");
|
|
252
|
+
byBranch.set(branch, list.map((item) => Number(item.number)));
|
|
253
|
+
});
|
|
254
|
+
const ids = [
|
|
255
|
+
...new Set(branches.flatMap((branch) => byBranch.get(branch) ?? [])),
|
|
256
|
+
].slice(0, limit);
|
|
257
|
+
await pooled(ids, 4, async (number) => {
|
|
258
|
+
initial.set(number, await inspect(number));
|
|
259
|
+
});
|
|
260
|
+
const failed = [...initial.values()].filter((row) => row.status === "failed" || row.status === "skipped");
|
|
261
|
+
for (const row of failed)
|
|
262
|
+
console.error(`#${row.number}: ${visible(row.detail)}`);
|
|
263
|
+
selected = await choose([...initial.values()].sort((a, b) => a.number - b.number), data.worktree, base, !!values["dry-run"]);
|
|
264
|
+
if (!selected.length) {
|
|
265
|
+
const report = {
|
|
266
|
+
repository: repository.name,
|
|
267
|
+
workspace: data.worktree,
|
|
268
|
+
base,
|
|
269
|
+
dryRun: !!values["dry-run"],
|
|
270
|
+
results: failed,
|
|
271
|
+
summary: { selected: 0, failures: failed.length },
|
|
272
|
+
};
|
|
273
|
+
if (values.json)
|
|
274
|
+
json(report);
|
|
275
|
+
else
|
|
276
|
+
console.log(`No RL session PRs selected for update.${failed.length ? ` ${failed.length} PRs could not be checked.` : ""}`);
|
|
277
|
+
return failed.length ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (selected.length > 50)
|
|
281
|
+
throw new Error("Select at most 50 PRs per update batch");
|
|
282
|
+
if (requested.length)
|
|
283
|
+
await pooled(selected, 4, async (number) => {
|
|
284
|
+
initial.set(number, await inspect(number));
|
|
285
|
+
});
|
|
286
|
+
const results = [];
|
|
287
|
+
for (const number of selected) {
|
|
288
|
+
let row = initial.get(number);
|
|
289
|
+
if (row.status !== "ready") {
|
|
290
|
+
results.push(row);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (values["dry-run"]) {
|
|
294
|
+
results.push({
|
|
295
|
+
...row,
|
|
296
|
+
status: "would-update",
|
|
297
|
+
detail: `Would merge latest ${base} into ${row.branch}${row.mergeability === "UNKNOWN" ? "; GitHub has not yet determined mergeability" : ""}`,
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
recheck();
|
|
303
|
+
const latest = await inspect(number);
|
|
304
|
+
if (latest.status !== "ready") {
|
|
305
|
+
results.push(latest);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (latest.headBefore !== row.headBefore ||
|
|
309
|
+
latest.branch !== row.branch) {
|
|
310
|
+
results.push({
|
|
311
|
+
...row,
|
|
312
|
+
status: "changed",
|
|
313
|
+
detail: "PR head changed since selection; review and retry",
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
row = latest;
|
|
318
|
+
if (!values.json)
|
|
319
|
+
console.error(`Merging latest ${base} into #${number} ${visible(row.branch)}…`);
|
|
320
|
+
recheck();
|
|
321
|
+
if (!ownsBranch(row.branch)) {
|
|
322
|
+
results.push({
|
|
323
|
+
...row,
|
|
324
|
+
status: "skipped",
|
|
325
|
+
detail: "PR branch no longer belongs to the selected active RL session",
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const update = await github(data.worktree, [
|
|
330
|
+
"api",
|
|
331
|
+
"--hostname",
|
|
332
|
+
hostname,
|
|
333
|
+
"--method",
|
|
334
|
+
"PUT",
|
|
335
|
+
`${endpoint}/pulls/${number}/update-branch`,
|
|
336
|
+
"-f",
|
|
337
|
+
`expected_head_sha=${row.headBefore}`,
|
|
338
|
+
]);
|
|
339
|
+
if (update.code) {
|
|
340
|
+
const error = apiError(update.stdout, update.stderr);
|
|
341
|
+
results.push({ ...row, status: errorStatus(error), detail: error });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// Acceptance is asynchronous. Only report updated after verifying base ancestry.
|
|
345
|
+
let verified = false;
|
|
346
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
347
|
+
if (attempt)
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
349
|
+
const observed = await inspect(number);
|
|
350
|
+
if (observed.status === "up-to-date") {
|
|
351
|
+
results.push({
|
|
352
|
+
...row,
|
|
353
|
+
status: "updated",
|
|
354
|
+
headAfter: observed.headBefore,
|
|
355
|
+
baseCommit: observed.baseCommit,
|
|
356
|
+
detail: `Latest ${base} is included in the PR branch`,
|
|
357
|
+
});
|
|
358
|
+
verified = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (["failed", "skipped", "conflict"].includes(observed.status)) {
|
|
362
|
+
results.push({
|
|
363
|
+
...row,
|
|
364
|
+
status: "pending",
|
|
365
|
+
detail: `Update accepted; verification incomplete: ${observed.detail}`,
|
|
366
|
+
});
|
|
367
|
+
verified = true;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!verified)
|
|
372
|
+
results.push({
|
|
373
|
+
...row,
|
|
374
|
+
status: "pending",
|
|
375
|
+
detail: `GitHub accepted the update; completion is not yet verified. Run rl pr update ${number} --base ${shellQuote(base)} --dry-run to recheck`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
results.push({ ...row, status: "failed", detail: message(error) });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const summary = Object.fromEntries([
|
|
383
|
+
"updated",
|
|
384
|
+
"would-update",
|
|
385
|
+
"up-to-date",
|
|
386
|
+
"conflict",
|
|
387
|
+
"changed",
|
|
388
|
+
"failed",
|
|
389
|
+
"pending",
|
|
390
|
+
"skipped",
|
|
391
|
+
].map((status) => [
|
|
392
|
+
status,
|
|
393
|
+
results.filter((row) => row.status === status).length,
|
|
394
|
+
]));
|
|
395
|
+
const report = {
|
|
396
|
+
repository: repository.name,
|
|
397
|
+
workspace: data.worktree,
|
|
398
|
+
base,
|
|
399
|
+
dryRun: !!values["dry-run"],
|
|
400
|
+
results,
|
|
401
|
+
summary: { selected: selected.length, ...summary },
|
|
402
|
+
};
|
|
403
|
+
if (values.json)
|
|
404
|
+
json(report);
|
|
405
|
+
else {
|
|
406
|
+
console.log(`\nPR updates: ${repository.name} · base ${visible(base)}${values["dry-run"] ? " · preview" : ""}\nPR RESULT BEHIND (COMMITS) BRANCH`);
|
|
407
|
+
for (const row of results) {
|
|
408
|
+
console.log(`${`#${row.number}`.padEnd(8)}${row.status.padEnd(15)}${String(row.behindCommits ?? "?").padEnd(18)}${visible(row.branch ?? "—")}`);
|
|
409
|
+
console.log(` ${visible(row.detail)}${row.url ? `\n ${row.url}` : ""}`);
|
|
410
|
+
}
|
|
411
|
+
console.log(`\n${results.length} selected · ${Object.entries(summary)
|
|
412
|
+
.filter(([, count]) => count)
|
|
413
|
+
.map(([status, count]) => `${count} ${status}`)
|
|
414
|
+
.join(" · ")}`);
|
|
415
|
+
}
|
|
416
|
+
return results.some((row) => ["conflict", "changed", "failed", "pending", "skipped"].includes(row.status))
|
|
417
|
+
? 1
|
|
418
|
+
: 0;
|
|
419
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { args, arity, canonical, execute, git, inside, locked, } from "./common.js";
|
|
5
|
+
import { commandContext, contextOptions, resolveWorkspace, } from "./command-context.js";
|
|
6
|
+
import { GIT_WORKFLOW_DIRECTORY, GIT_WORKFLOW_LOCK } from "./git-safety.js";
|
|
7
|
+
import { isJsonObject, parseJsonObject } from "./json.js";
|
|
8
|
+
const GH_TIMEOUT_MS = 20_000;
|
|
9
|
+
const PR_FIELDS = "number,url,headRefName,headRefOid,baseRefName,isCrossRepository,headRepository,headRepositoryOwner";
|
|
10
|
+
const CHECK_FIELDS = "name,state,bucket,link,workflow";
|
|
11
|
+
const FETCH_PREFIX = "refs/rl/pr-fetch/";
|
|
12
|
+
/** Bound requests and ignore a caller's unrelated GH_REPO override. */
|
|
13
|
+
export async function github(repo, argv) {
|
|
14
|
+
if (process.env.RL_PR_OFFLINE === "1")
|
|
15
|
+
throw new Error("GitHub is offline (RL_PR_OFFLINE=1)");
|
|
16
|
+
const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
|
|
17
|
+
delete env.GH_REPO;
|
|
18
|
+
return execute(["gh", ...argv], repo, { timeoutMs: GH_TIMEOUT_MS, env });
|
|
19
|
+
}
|
|
20
|
+
/** Bind GitHub reads and Git fetches to the same origin before changing local refs. */
|
|
21
|
+
export async function repositoryIdentity(repo) {
|
|
22
|
+
const result = await github(repo, [
|
|
23
|
+
"repo",
|
|
24
|
+
"view",
|
|
25
|
+
"--json",
|
|
26
|
+
"nameWithOwner,url",
|
|
27
|
+
]);
|
|
28
|
+
if (result.code)
|
|
29
|
+
throw new Error(result.stderr.trim() || "Cannot determine GitHub repository");
|
|
30
|
+
const raw = parseJsonObject(result.stdout);
|
|
31
|
+
if (typeof raw.nameWithOwner !== "string" ||
|
|
32
|
+
typeof raw.url !== "string" ||
|
|
33
|
+
!/^[\w.-]+\/[\w.-]+$/.test(raw.nameWithOwner))
|
|
34
|
+
throw new Error("Invalid GitHub repository identity");
|
|
35
|
+
const url = new URL(raw.url);
|
|
36
|
+
if (url.protocol !== "https:" ||
|
|
37
|
+
url.username ||
|
|
38
|
+
url.password ||
|
|
39
|
+
url.search ||
|
|
40
|
+
url.hash ||
|
|
41
|
+
url.pathname !== `/${raw.nameWithOwner}`)
|
|
42
|
+
throw new Error("Invalid GitHub repository URL");
|
|
43
|
+
const remote = git(repo, "remote", "get-url", "origin");
|
|
44
|
+
const expected = `${url.host}/${raw.nameWithOwner}`.toLowerCase();
|
|
45
|
+
const normalized = remote
|
|
46
|
+
.replace(/^https:\/\//, "")
|
|
47
|
+
.replace(/^ssh:\/\/git@/, "")
|
|
48
|
+
.replace(/^git@([^:]+):/, "$1/")
|
|
49
|
+
.replace(/\.git$/, "")
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
if (normalized !== expected)
|
|
52
|
+
throw new Error("GitHub repository identity does not match origin");
|
|
53
|
+
return { name: raw.nameWithOwner, url: raw.url };
|
|
54
|
+
}
|
|
55
|
+
export async function main(argv) {
|
|
56
|
+
const { values, positionals } = args(argv, [...contextOptions, "name"], ["json"]);
|
|
57
|
+
const context = commandContext(values);
|
|
58
|
+
const [command, action, ...rest] = positionals;
|
|
59
|
+
if (command !== "pr" || !["checks", "checkout"].includes(action))
|
|
60
|
+
throw new Error("Usage: rl pr checks|checkout");
|
|
61
|
+
const identity = await repositoryIdentity(context.repo);
|
|
62
|
+
if (action === "checks") {
|
|
63
|
+
arity(rest, 0);
|
|
64
|
+
if (values.name)
|
|
65
|
+
throw new Error("checks does not accept --name");
|
|
66
|
+
const workspace = resolveWorkspace(context);
|
|
67
|
+
const result = await github(workspace.path, [
|
|
68
|
+
"pr",
|
|
69
|
+
"checks",
|
|
70
|
+
workspace.branch,
|
|
71
|
+
"--repo",
|
|
72
|
+
identity.url,
|
|
73
|
+
...(values.json ? ["--json", CHECK_FIELDS] : []),
|
|
74
|
+
]);
|
|
75
|
+
process.stdout.write(result.stdout);
|
|
76
|
+
if (result.stderr)
|
|
77
|
+
process.stderr.write(result.stderr);
|
|
78
|
+
return result.code;
|
|
79
|
+
}
|
|
80
|
+
arity(rest, 1);
|
|
81
|
+
if (values.json)
|
|
82
|
+
throw new Error("checkout does not accept --json");
|
|
83
|
+
const [number] = rest;
|
|
84
|
+
if (!/^[1-9]\d*$/.test(number))
|
|
85
|
+
throw new Error("Expected a positive PR number");
|
|
86
|
+
const name = String(values.name ?? `pr-${number}`);
|
|
87
|
+
if (name.startsWith("-") || /[\r\n]/.test(name))
|
|
88
|
+
throw new Error("Invalid workspace name");
|
|
89
|
+
if (git(context.repo, "check-ref-format", "--branch", name) !== name)
|
|
90
|
+
throw new Error("Workspace name must be a literal branch name");
|
|
91
|
+
const destination = canonical(path.join(context.managedDir, name));
|
|
92
|
+
if (!inside(destination, context.managedDir) || fs.existsSync(destination))
|
|
93
|
+
throw new Error("Workspace destination must be a new path inside the managed directory");
|
|
94
|
+
const result = await github(context.repo, [
|
|
95
|
+
"pr",
|
|
96
|
+
"view",
|
|
97
|
+
number,
|
|
98
|
+
"--repo",
|
|
99
|
+
identity.url,
|
|
100
|
+
"--json",
|
|
101
|
+
PR_FIELDS,
|
|
102
|
+
]);
|
|
103
|
+
if (result.code)
|
|
104
|
+
throw new Error(result.stderr.trim() || "Cannot load PR metadata");
|
|
105
|
+
const raw = parseJsonObject(result.stdout);
|
|
106
|
+
if (raw.number !== Number(number) ||
|
|
107
|
+
raw.url !== `${identity.url}/pull/${number}` ||
|
|
108
|
+
raw.isCrossRepository !== false ||
|
|
109
|
+
!isJsonObject(raw.headRepository) ||
|
|
110
|
+
!isJsonObject(raw.headRepositoryOwner) ||
|
|
111
|
+
typeof raw.headRepository.name !== "string" ||
|
|
112
|
+
typeof raw.headRepositoryOwner.login !== "string" ||
|
|
113
|
+
`${raw.headRepositoryOwner.login}/${raw.headRepository.name}`.toLowerCase() !==
|
|
114
|
+
identity.name.toLowerCase() ||
|
|
115
|
+
typeof raw.headRefName !== "string" ||
|
|
116
|
+
typeof raw.baseRefName !== "string" ||
|
|
117
|
+
typeof raw.headRefOid !== "string" ||
|
|
118
|
+
!/^[a-f0-9]{40,64}$/.test(raw.headRefOid))
|
|
119
|
+
throw new Error("PR identity is invalid or belongs to a fork; fork checkouts are not supported");
|
|
120
|
+
const head = raw.headRefOid;
|
|
121
|
+
const base = raw.baseRefName;
|
|
122
|
+
git(context.repo, "check-ref-format", `refs/heads/${raw.headRefName}`);
|
|
123
|
+
git(context.repo, "check-ref-format", `refs/heads/${base}`);
|
|
124
|
+
return locked(path.join(context.store.root, GIT_WORKFLOW_DIRECTORY), GIT_WORKFLOW_LOCK, async () => {
|
|
125
|
+
if (fs.existsSync(destination))
|
|
126
|
+
throw new Error("Workspace already exists");
|
|
127
|
+
const existing = await execute([
|
|
128
|
+
"git",
|
|
129
|
+
"-C",
|
|
130
|
+
context.repo,
|
|
131
|
+
"show-ref",
|
|
132
|
+
"--verify",
|
|
133
|
+
"--quiet",
|
|
134
|
+
`refs/heads/${name}`,
|
|
135
|
+
], context.repo);
|
|
136
|
+
if (existing.code === 0)
|
|
137
|
+
throw new Error(`Local branch already exists: ${name}`);
|
|
138
|
+
if (existing.code !== 1)
|
|
139
|
+
throw new Error(existing.stderr || "Cannot inspect local branch");
|
|
140
|
+
const temporary = FETCH_PREFIX + randomUUID();
|
|
141
|
+
try {
|
|
142
|
+
git(context.repo, "fetch", "--no-tags", "origin", `refs/pull/${number}/head:${temporary}`);
|
|
143
|
+
if (git(context.repo, "rev-parse", "--verify", `${temporary}^{commit}`) !== head)
|
|
144
|
+
throw new Error("PR changed during checkout; retry to fetch its current head");
|
|
145
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
146
|
+
git(context.repo, "worktree", "add", "-b", name, destination, head);
|
|
147
|
+
try {
|
|
148
|
+
await context.store.adopt(destination, base);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// Preserve the valid checkout on adoption failure so hooks/files can be inspected.
|
|
152
|
+
throw new Error(`Created ${destination}, but RL adoption failed: ${error instanceof Error ? error.message : "unknown error"}. Run rl adopt inside it`);
|
|
153
|
+
}
|
|
154
|
+
console.log(destination);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
git(context.repo, "update-ref", "-d", temporary);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|