@coderook/cli 0.36.0 → 0.38.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.
@@ -1,403 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.forTests = void 0;
7
- exports.commandGithub = commandGithub;
8
- /**
9
- * `cbx github` — proposals reviewed and tested on GitHub, results on CodeRook.
10
- *
11
- * CodeRabbit, Blacksmith and most CI only connect to GitHub-shaped hosts. So
12
- * rather than each of them learning CodeRook, a GitHub repository is used as
13
- * a mirror: every open proposal becomes a pull request there, whatever is
14
- * installed on that repository reviews and builds it, and what they conclude
15
- * comes back to the proposal as checks — where a required one stops it being
16
- * applied, exactly like a check reported any other way.
17
- *
18
- * It runs on GitHub, in a workflow `setup` writes into the project, so
19
- * CodeRook never holds a GitHub credential and the mirror costs the service
20
- * nothing. Each side holds only the other's narrowest key: GitHub keeps a
21
- * CodeRook token limited to this one project that can read it and report
22
- * checks, and nothing else; the push token stays in GitHub's secrets.
23
- *
24
- * Commits are rebuilt by the git remote helper, which gives the same commit
25
- * for the same version every time, so the mirror only ever moves forward.
26
- */
27
- const node_child_process_1 = require("node:child_process");
28
- const node_fs_1 = require("node:fs");
29
- const promises_1 = require("node:fs/promises");
30
- const node_os_1 = __importDefault(require("node:os"));
31
- const node_path_1 = __importDefault(require("node:path"));
32
- const node_process_1 = __importDefault(require("node:process"));
33
- const api_js_1 = require("./api.js");
34
- const colour_js_1 = require("./colour.js");
35
- const config_js_1 = require("./config.js");
36
- const WORKFLOW_PATH = ".github/workflows/coderook-bridge.yml";
37
- /** The bridge's own job, left out of what is reported back. */
38
- const BRIDGE_JOB = "sync with CodeRook";
39
- const MARKER = (id) => `<!-- coderook-proposal:${id} -->`;
40
- const MARKER_PATTERN = /<!-- coderook-proposal:([0-9a-f-]{36}) -->/;
41
- const flag = (parsed, name) => {
42
- const value = parsed.flags.get(name);
43
- return typeof value === "string" ? value : undefined;
44
- };
45
- async function gh(token, route, method = "GET", body) {
46
- const response = await fetch(`https://api.github.com${route}`, {
47
- method,
48
- headers: {
49
- accept: "application/vnd.github+json",
50
- authorization: `Bearer ${token}`,
51
- "x-github-api-version": "2022-11-28",
52
- "user-agent": "CodeRook-CLI",
53
- ...(body ? { "content-type": "application/json" } : {}),
54
- },
55
- body: body ? JSON.stringify(body) : undefined,
56
- signal: AbortSignal.timeout(30_000),
57
- });
58
- if (!response.ok) {
59
- const text = await response.text().catch(() => "");
60
- let message = text.slice(0, 300);
61
- try {
62
- message = JSON.parse(text).message ?? message;
63
- }
64
- catch {
65
- /* not JSON */
66
- }
67
- throw new Error(`GitHub ${method} ${route.split("?")[0]}: ${response.status} ${message}`);
68
- }
69
- return (response.status === 204 ? undefined : await response.json());
70
- }
71
- /** Every page of a GitHub list, up to a sensible ceiling. */
72
- async function ghAll(token, route, key) {
73
- const all = [];
74
- for (let page = 1; page <= 10; page += 1) {
75
- const joiner = route.includes("?") ? "&" : "?";
76
- const body = await gh(token, `${route}${joiner}per_page=100&page=${page}`);
77
- const rows = (key ? body[key] : body);
78
- all.push(...rows);
79
- if (rows.length < 100)
80
- break;
81
- }
82
- return all;
83
- }
84
- function githubToken() {
85
- for (const name of ["CODEROOK_BRIDGE_PUSH", "GH_TOKEN", "GITHUB_TOKEN"]) {
86
- const value = node_process_1.default.env[name]?.trim();
87
- if (value)
88
- return value;
89
- }
90
- /* On somebody's own machine, the GitHub CLI's sign-in will do. */
91
- const asked = (0, node_child_process_1.spawnSync)("gh", ["auth", "token"], { encoding: "utf8" });
92
- const token = asked.status === 0 ? asked.stdout.trim() : "";
93
- return token || null;
94
- }
95
- /* ------------------------------------------------------------------ git */
96
- function git(cwd, args, env = node_process_1.default.env, quiet = true) {
97
- const run = (0, node_child_process_1.spawnSync)("git", args, {
98
- cwd,
99
- env,
100
- encoding: "utf8",
101
- stdio: quiet ? "pipe" : ["ignore", "inherit", "inherit"],
102
- maxBuffer: 64 * 1024 * 1024,
103
- });
104
- return { ok: run.status === 0, out: run.stdout ?? "", err: run.stderr ?? "" };
105
- }
106
- /**
107
- * The push token as an HTTP header git sends, passed through git's
108
- * environment-config variables so it never appears in an argument list or a
109
- * remote URL that `git remote -v` would print.
110
- */
111
- function pushEnvironment(token) {
112
- const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
113
- return {
114
- ...node_process_1.default.env,
115
- GIT_TERMINAL_PROMPT: "0",
116
- GIT_CONFIG_COUNT: "1",
117
- GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
118
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`,
119
- };
120
- }
121
- /* ------------------------------------------------------------------ setup */
122
- function workflow(project, required) {
123
- return `# Mirrors this project's proposals to pull requests here, so tools that only
124
- # connect to GitHub (CodeRabbit, Blacksmith, any GitHub Actions CI) can review
125
- # and test them, and reports what they conclude back to each proposal on
126
- # CodeRook as checks. Written by \`cbx github setup\`; see
127
- # https://coderook.com/docs#github-bridge
128
- #
129
- # Secrets this needs, in this GitHub repository's settings:
130
- # CODEROOK_TOKEN a CodeRook token limited to this project (setup made it)
131
- # CODEROOK_BRIDGE_PUSH a GitHub token that can push and open pull requests here.
132
- # GitHub's own workflow token cannot: what it pushes starts
133
- # no other workflow, so CI would never run on a proposal.
134
- name: CodeRook bridge
135
-
136
- on:
137
- schedule:
138
- - cron: "*/10 * * * *"
139
- workflow_dispatch:
140
- pull_request_review:
141
- types: [submitted]
142
-
143
- concurrency:
144
- group: coderook-bridge
145
- cancel-in-progress: false
146
-
147
- permissions:
148
- contents: read
149
- pull-requests: read
150
- checks: read
151
- statuses: read
152
-
153
- jobs:
154
- sync:
155
- name: ${BRIDGE_JOB}
156
- runs-on: ubuntu-latest
157
- steps:
158
- - uses: actions/checkout@v4
159
- with:
160
- fetch-depth: 0
161
- persist-credentials: false
162
- # What was already imported, so each run fetches only new versions.
163
- - uses: actions/cache@v4
164
- with:
165
- path: .git/coderook
166
- key: coderook-marks-\${{ github.run_id }}
167
- restore-keys: coderook-marks-
168
- - uses: actions/setup-node@v4
169
- with:
170
- node-version: 22
171
- - run: npm install --global @coderook/cli@latest
172
- - run: cbx github sync
173
- env:
174
- CODEROOK_TOKEN: \${{ secrets.CODEROOK_TOKEN }}
175
- CODEROOK_BRIDGE_PUSH: \${{ secrets.CODEROOK_BRIDGE_PUSH }}
176
- GITHUB_READ_TOKEN: \${{ github.token }}
177
- CODEROOK_PROJECT: ${project}
178
- # Checks that must pass before a proposal can be applied, by name, comma separated:
179
- # e.g. "github/build, review/coderabbitai[bot]". Everything else is reported as advice.
180
- CODEROOK_REQUIRED: "${required}"
181
- `;
182
- }
183
- async function setup(parsed) {
184
- const repo = parsed.positional[1];
185
- if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) {
186
- console.error((0, colour_js_1.red)("Name the GitHub repository to mirror into: cbx github setup <owner>/<repo>"));
187
- return 1;
188
- }
189
- const folder = node_path_1.default.resolve(parsed.positional[2] ?? ".");
190
- const link = await (0, config_js_1.readLink)(folder);
191
- if (!link) {
192
- console.error((0, colour_js_1.red)("This folder is not linked to a project. Run it in the project's folder."));
193
- return 1;
194
- }
195
- const file = node_path_1.default.join(folder, WORKFLOW_PATH);
196
- if ((0, node_fs_1.existsSync)(file) && !parsed.flags.has("force")) {
197
- console.error((0, colour_js_1.red)(`${WORKFLOW_PATH} is already here. Pass --force to write it again.`));
198
- return 1;
199
- }
200
- const token = await (0, api_js_1.createToken)({
201
- name: `GitHub bridge: ${repo}`.slice(0, 80),
202
- scopes: ["projects:read", "checks:write"],
203
- repositoryId: link.repositoryId,
204
- expiresInDays: 365,
205
- });
206
- await (0, promises_1.mkdir)(node_path_1.default.dirname(file), { recursive: true });
207
- await (0, promises_1.writeFile)(file, workflow(link.slug, flag(parsed, "required") ?? ""), "utf8");
208
- console.log(`${(0, colour_js_1.green)("Wrote")} ${WORKFLOW_PATH}`);
209
- /* Straight into GitHub's secrets when the GitHub CLI is here, so the token is never on screen. */
210
- const stored = (0, node_child_process_1.spawnSync)("gh", ["secret", "set", "CODEROOK_TOKEN", "--repo", repo], {
211
- input: token.token,
212
- encoding: "utf8",
213
- });
214
- if (stored.status === 0) {
215
- console.log(`${(0, colour_js_1.green)("Stored")} CODEROOK_TOKEN in ${repo}'s secrets. It reaches ${(0, colour_js_1.bold)(link.slug)} only, and can read it and report checks.`);
216
- }
217
- else {
218
- console.log("");
219
- console.log(`Add this as the secret ${(0, colour_js_1.bold)("CODEROOK_TOKEN")} in ${repo} (Settings → Secrets and variables → Actions).`);
220
- console.log("It is shown once, reaches this project only, and can read it and report checks:");
221
- console.log(` ${token.token}`);
222
- }
223
- console.log("");
224
- console.log("Then:");
225
- console.log(` 1. Add ${(0, colour_js_1.bold)("CODEROOK_BRIDGE_PUSH")}: a GitHub fine-grained token for ${repo} with`);
226
- console.log(" Contents and Pull requests set to read and write.");
227
- console.log(` 2. Save the workflow into the project: ${(0, colour_js_1.accent)("cbx submit")}`);
228
- console.log(` 3. Mirror it once from here, which also puts the workflow on GitHub: ${(0, colour_js_1.accent)(`cbx github sync --repo ${repo}`)}`);
229
- console.log((0, colour_js_1.dim)(" After that it runs on GitHub every ten minutes, and whenever a review is submitted."));
230
- console.log((0, colour_js_1.dim)(" Install CodeRabbit on the repository, or use Blacksmith runners in your workflows, as you would anywhere."));
231
- return 0;
232
- }
233
- function fromCheckRun(run) {
234
- const state = run.status !== "completed"
235
- ? "pending"
236
- : ["success", "neutral"].includes(run.conclusion ?? "")
237
- ? "passing"
238
- : run.conclusion === "skipped"
239
- ? "skipped"
240
- : "failing";
241
- return {
242
- name: `github/${run.name}`,
243
- state,
244
- detail: run.output?.title || (run.status === "completed" ? run.conclusion ?? "" : run.status),
245
- url: run.html_url,
246
- };
247
- }
248
- function fromStatus(status) {
249
- const state = status.state === "success" ? "passing" : status.state === "pending" ? "pending" : "failing";
250
- return { name: `github/${status.context}`, state, detail: status.description ?? "", url: status.target_url };
251
- }
252
- /** The last word from each reviewer: approved passes, changes requested fails, a comment is advice. */
253
- function fromReviews(reviews) {
254
- const latest = new Map();
255
- for (const review of reviews) {
256
- const login = review.user?.login;
257
- if (!login || review.state === "PENDING" || review.state === "DISMISSED")
258
- continue;
259
- const state = review.state === "APPROVED" ? "passing" : review.state === "CHANGES_REQUESTED" ? "failing" : "passing";
260
- const said = (review.body ?? "").replace(/\s+/g, " ").trim();
261
- latest.set(login, {
262
- name: `review/${login}`,
263
- state,
264
- detail: (review.state === "COMMENTED" ? "commented" : review.state.toLowerCase().replace(/_/g, " ")) + (said ? `: ${said}` : ""),
265
- url: review.html_url,
266
- });
267
- }
268
- return [...latest.values()];
269
- }
270
- async function sync(parsed) {
271
- const repo = flag(parsed, "repo") ?? node_process_1.default.env.GITHUB_REPOSITORY;
272
- if (!repo) {
273
- console.error((0, colour_js_1.red)("Which GitHub repository? Pass --repo <owner>/<repo>."));
274
- return 1;
275
- }
276
- const write = githubToken();
277
- if (!write) {
278
- console.error((0, colour_js_1.red)("No GitHub token. Set CODEROOK_BRIDGE_PUSH (or GH_TOKEN), or sign in with the GitHub CLI."));
279
- return 1;
280
- }
281
- const github = { repo, write, read: node_process_1.default.env.GITHUB_READ_TOKEN?.trim() || write };
282
- const required = new Set((flag(parsed, "required") ?? node_process_1.default.env.CODEROOK_REQUIRED ?? "").split(",").map((name) => name.trim()).filter(Boolean));
283
- /* The project: named, or the one this folder (or the token) belongs to. */
284
- const named = flag(parsed, "project") ?? node_process_1.default.env.CODEROOK_PROJECT;
285
- const link = named ? null : await (0, config_js_1.readLink)(node_process_1.default.cwd());
286
- const listed = await (0, api_js_1.projects)();
287
- const project = listed.find((one) => one.id === named || one.slug === named) ??
288
- (link ? listed.find((one) => one.id === link.repositoryId) : undefined) ??
289
- (listed.length === 1 ? listed[0] : undefined);
290
- if (!project) {
291
- console.error((0, colour_js_1.red)(named ? `No project called ${named} on this account.` : "Name the project: --project <slug>."));
292
- return 1;
293
- }
294
- /* A git repository to work in: this one on GitHub's runner, a scratch one on somebody's machine. */
295
- const here = git(node_process_1.default.cwd(), ["rev-parse", "--is-inside-work-tree"]).ok && Boolean(node_process_1.default.env.GITHUB_ACTIONS);
296
- const work = here ? node_process_1.default.cwd() : await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "cbx-github-"));
297
- try {
298
- if (!here)
299
- git(work, ["init", "--quiet"]);
300
- git(work, ["remote", "remove", "coderook"]);
301
- git(work, ["remote", "add", "coderook", `coderook://${project.slug}`]);
302
- console.log(`Fetching ${(0, colour_js_1.bold)(project.slug)} from CodeRook…`);
303
- const fetched = git(work, ["fetch", "--quiet", "coderook"], node_process_1.default.env, false);
304
- if (!fetched.ok) {
305
- console.error((0, colour_js_1.red)("Could not fetch the project from CodeRook."));
306
- return 1;
307
- }
308
- const open = (await (0, api_js_1.proposals)(project.id)).filter((one) => one.proposal?.from && one.proposal?.into);
309
- const lines = new Set([project.defaultBranch || "main"]);
310
- for (const one of open) {
311
- lines.add(one.proposal.from);
312
- lines.add(one.proposal.into);
313
- }
314
- /* Each line to the branch of the same name. Never forced: the mirror only moves forward. */
315
- const pushEnv = pushEnvironment(github.write);
316
- const pushed = new Set();
317
- for (const line of lines) {
318
- if (!git(work, ["rev-parse", "--verify", "--quiet", `refs/remotes/coderook/${line}`]).ok)
319
- continue;
320
- const result = git(work, ["push", "--quiet", `https://github.com/${repo}.git`, `refs/remotes/coderook/${line}:refs/heads/${line}`], pushEnv);
321
- if (result.ok) {
322
- pushed.add(line);
323
- }
324
- else {
325
- console.error((0, colour_js_1.accent)(` ${line}: not pushed. ${result.err.trim().split("\n").pop() ?? ""}`));
326
- console.error((0, colour_js_1.dim)(" The branch on GitHub has commits CodeRook does not. Mirror into a repository nothing else pushes to."));
327
- }
328
- }
329
- console.log(`${(0, colour_js_1.green)("Mirrored")} ${pushed.size} line${pushed.size === 1 ? "" : "s"} to ${repo}`);
330
- /* Pull requests: one per open proposal, found again by the marker in its description. */
331
- const existing = await ghAll(github.write, `/repos/${repo}/pulls?state=all`);
332
- const byProposal = new Map();
333
- for (const pull of existing) {
334
- const match = MARKER_PATTERN.exec(pull.body ?? "");
335
- if (match && !byProposal.has(match[1]))
336
- byProposal.set(match[1], pull);
337
- }
338
- const site = (node_process_1.default.env.CODEROOK_SITE_URL ?? "https://coderook.com").replace(/\/+$/, "");
339
- let reported = 0;
340
- for (const one of open) {
341
- const from = one.proposal.from;
342
- const into = one.proposal.into;
343
- if (!pushed.has(from) || !pushed.has(into))
344
- continue;
345
- const title = `${one.proposal.title ?? `${from} into ${into}`} (${one.reference})`;
346
- const body = `Mirrored from CodeRook proposal ${one.reference} so the tools installed here can review and test it. ` +
347
- `Results are reported back to it as checks; apply it on CodeRook, not here.\n\n` +
348
- `${site}/merges?merge=${one.id}\n\n${MARKER(one.id)}`;
349
- let pull = byProposal.get(one.id);
350
- if (!pull) {
351
- pull = await gh(github.write, `/repos/${repo}/pulls`, "POST", { title, head: from, base: into, body, maintainer_can_modify: false });
352
- console.log(`${(0, colour_js_1.green)("Opened")} ${pull.html_url} for ${one.reference}`);
353
- }
354
- else if (pull.state !== "open" || pull.title !== title) {
355
- pull = await gh(github.write, `/repos/${repo}/pulls/${pull.number}`, "PATCH", { title, state: "open", body });
356
- }
357
- /* What GitHub's side concluded, back to the proposal; only what changed is reported. */
358
- const head = (await gh(github.read, `/repos/${repo}/pulls/${pull.number}`)).head.sha;
359
- const found = [];
360
- const runs = await ghAll(github.read, `/repos/${repo}/commits/${head}/check-runs`, "check_runs");
361
- found.push(...runs.filter((run) => run.name !== BRIDGE_JOB).map(fromCheckRun));
362
- const combined = await gh(github.read, `/repos/${repo}/commits/${head}/status`);
363
- found.push(...combined.statuses.map(fromStatus));
364
- found.push(...fromReviews(await ghAll(github.read, `/repos/${repo}/pulls/${pull.number}/reviews`)));
365
- const current = new Map((await (0, api_js_1.mergeTrack)(one.id)).checks?.map((check) => [check.name, check]) ?? []);
366
- for (const check of found) {
367
- const name = check.name.slice(0, 100);
368
- const detail = check.detail.slice(0, 2000);
369
- const before = current.get(name);
370
- const url = check.url && /^https:\/\//.test(check.url) ? check.url : null;
371
- if (before && before.state === check.state && (before.detail ?? "") === detail)
372
- continue;
373
- await (0, api_js_1.reportMergeCheck)(one.id, name, { state: check.state, required: required.has(name), detail, detailsUrl: url });
374
- reported += 1;
375
- }
376
- }
377
- /* A proposal applied or withdrawn on CodeRook closes its pull request here. */
378
- const stillOpen = new Set(open.map((one) => one.id));
379
- for (const [id, pull] of byProposal) {
380
- if (pull.state === "open" && !stillOpen.has(id)) {
381
- await gh(github.write, `/repos/${repo}/pulls/${pull.number}`, "PATCH", { state: "closed" });
382
- console.log(`${(0, colour_js_1.dim)("Closed")} ${pull.html_url}: its proposal is no longer open`);
383
- }
384
- }
385
- console.log(`${(0, colour_js_1.green)("Reported")} ${reported} check result${reported === 1 ? "" : "s"} to CodeRook`);
386
- return 0;
387
- }
388
- finally {
389
- if (!here)
390
- await (0, promises_1.rm)(work, { recursive: true, force: true }).catch(() => undefined);
391
- }
392
- }
393
- async function commandGithub(parsed) {
394
- const sub = parsed.positional[0];
395
- if (sub === "setup")
396
- return setup(parsed);
397
- if (sub === "sync")
398
- return sync(parsed);
399
- console.error((0, colour_js_1.red)(sub ? `cbx github has no "${sub}". ` : "") + "See: " + (0, colour_js_1.accent)("cbx help github"));
400
- return 1;
401
- }
402
- /** For tests: the workflow text and the mappings. */
403
- exports.forTests = { workflow, fromCheckRun, fromStatus, fromReviews };