@coderook/cli 0.35.0 → 0.36.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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
5
- "version": "0.35.0",
5
+ "version": "0.36.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
package/README.md CHANGED
@@ -194,6 +194,33 @@ Tests whenever a line is proposed or saved onto, and its result is a required
194
194
  check. `--on-save` runs an action on every save, and `--by-hand` only when
195
195
  somebody starts it.
196
196
 
197
+ Every proposal also has a `coderook/malware` check: what the line adds or
198
+ changes is scanned before it can land, and a finding names the file and stops
199
+ the proposal being applied.
200
+
201
+ ### Reviewing proposals on GitHub
202
+
203
+ CodeRabbit, Blacksmith and most CI only connect to GitHub, so `cbx github`
204
+ keeps a GitHub repository as a mirror: each open proposal becomes a pull
205
+ request there, whatever is installed on it reviews and builds it, and the
206
+ results come back to the proposal as checks (`github/<check>`,
207
+ `review/<reviewer>`).
208
+
209
+ ```
210
+ cbx github setup my-name/my-game-mirror token, secret, workflow
211
+ cbx submit save the workflow into the project
212
+ cbx github sync --repo my-name/my-game-mirror mirror once from here
213
+ ```
214
+
215
+ `setup` makes a token that reaches this project only and can only read it and
216
+ report checks, stores it as the repository's `CODEROOK_TOKEN` secret (with the
217
+ GitHub CLI; otherwise it prints it once), and writes
218
+ `.github/workflows/coderook-bridge.yml`, which then runs on GitHub every ten
219
+ minutes. Add `CODEROOK_BRIDGE_PUSH`, a fine-grained GitHub token with Contents
220
+ and Pull requests read and write: GitHub's own workflow token cannot start
221
+ other workflows, so CI would never run. Results are advice unless named in
222
+ `CODEROOK_REQUIRED` (or `--required`). Use a repository nothing else pushes to.
223
+
197
224
  A **protected** line refuses saves made straight onto it — from `cbx submit`,
198
225
  the desktop application and `git push` alike — so the way in is a line of your
199
226
  own and a proposal. It also cannot be put back on an earlier save, and a merge
@@ -452,6 +479,10 @@ no need for a second project that only holds releases. `--downloads private`
452
479
  takes them back. Without a release name it changes an existing release and
453
480
  leaves its notes alone.
454
481
 
482
+ Every attached file is scanned for malware before anybody outside the project
483
+ can download it, usually within a minute; one that fails is never offered, and
484
+ whoever attached it and the owner are told what was found.
485
+
455
486
  `cbx releases` marks each one `public`, `downloads public` or `private`, and
456
487
  for a public project prints the addresses an app or a script reads, which
457
488
  need no account:
@@ -45,6 +45,8 @@ exports.changeWorkflow = changeWorkflow;
45
45
  exports.runs = runs;
46
46
  exports.runLogs = runLogs;
47
47
  exports.tokens = tokens;
48
+ exports.createToken = createToken;
49
+ exports.reportMergeCheck = reportMergeCheck;
48
50
  exports.revokeToken = revokeToken;
49
51
  exports.deleteProject = deleteProject;
50
52
  exports.projectLabels = projectLabels;
@@ -806,6 +808,20 @@ async function tokens() {
806
808
  createdAt: String(row.createdAt ?? ""),
807
809
  }));
808
810
  }
811
+ /** Make a token. Limited to one project when `repositoryId` is given; shown once. */
812
+ async function createToken(input) {
813
+ return call("/v1/account/tokens", {
814
+ method: "POST",
815
+ body: input,
816
+ });
817
+ }
818
+ /** Say how a check on a proposal went; the same name again replaces it. */
819
+ async function reportMergeCheck(mergeTrackId, name, input) {
820
+ await call(`/v1/merge-tracks/${mergeTrackId}/checks/${encodeURIComponent(name)}`, {
821
+ method: "PUT",
822
+ body: input,
823
+ });
824
+ }
809
825
  async function revokeToken(tokenId) {
810
826
  await call(`/v1/account/tokens/${encodeURIComponent(tokenId)}`, {
811
827
  method: "DELETE",
@@ -32,6 +32,7 @@ const build_command_js_1 = require("./build_command.js");
32
32
  const node_check_js_1 = require("./node_check.js");
33
33
  const pages_command_js_1 = require("./pages_command.js");
34
34
  const mods_command_js_1 = require("./mods_command.js");
35
+ const github_bridge_js_1 = require("./github_bridge.js");
35
36
  const project_commands_js_1 = require("./project_commands.js");
36
37
  const track_commands_js_1 = require("./track_commands.js");
37
38
  const tracks_js_1 = require("../../cbx/src/core/tracks.js");
@@ -2793,6 +2794,35 @@ const SPECS = [
2793
2794
  ],
2794
2795
  run: pages_command_js_1.commandPages,
2795
2796
  },
2797
+ {
2798
+ name: "github",
2799
+ group: "Your projects",
2800
+ summary: "review and test proposals with GitHub tools (CodeRabbit, Blacksmith, Actions)",
2801
+ usage: "github <setup|sync> …",
2802
+ detail: "Mirrors a project's open proposals to pull requests in a GitHub repository,\n" +
2803
+ "so tools that only connect to GitHub review and build them, and reports\n" +
2804
+ "what they conclude back to each proposal as checks. A required check that\n" +
2805
+ "fails stops the proposal being applied, like any other check.\n\n" +
2806
+ "It runs on GitHub, in a workflow setup writes into the project. CodeRook\n" +
2807
+ "never holds a GitHub credential; GitHub holds a CodeRook token that reaches\n" +
2808
+ "this project only and can read it and report checks, nothing else.\n\n" +
2809
+ " setup <owner>/<repo> [folder] make the token, write the workflow\n" +
2810
+ " sync [--repo <owner>/<repo>] mirror and report once (what the workflow runs)\n\n" +
2811
+ "Use a GitHub repository nothing else pushes to: the mirror only moves\n" +
2812
+ "forward, and a branch changed on GitHub is left alone and reported.",
2813
+ options: [
2814
+ { flags: "--repo <owner/repo>", description: "sync: the GitHub repository (default: the one the workflow runs in)" },
2815
+ { flags: "--project <slug>", description: "sync: the CodeRook project (default: this folder's)" },
2816
+ { flags: "--required <names>", description: "checks that must pass, e.g. \"github/build,review/coderabbitai[bot]\"" },
2817
+ { flags: "--force", description: "setup: write the workflow again" },
2818
+ ],
2819
+ examples: [
2820
+ "cbx github setup my-name/my-game-mirror",
2821
+ "cbx submit",
2822
+ "cbx github sync --repo my-name/my-game-mirror",
2823
+ ],
2824
+ run: github_bridge_js_1.commandGithub,
2825
+ },
2796
2826
  {
2797
2827
  name: "mods",
2798
2828
  group: "Your projects",
@@ -0,0 +1,403 @@
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
@@ -306,6 +306,25 @@ unasked. `--ship` attaches the results to the newest save, so **ask before
306
306
  `--ship`**, as before any save. A project's own build description lives in
307
307
  `coderook.build.json`.
308
308
 
309
+ ## Reviewing proposals with GitHub tools
310
+
311
+ When someone wants CodeRabbit, Blacksmith or GitHub Actions on their CodeRook
312
+ proposals, that is `cbx github`: a GitHub repository mirrors each open
313
+ proposal as a pull request, and the results come back as checks named
314
+ `github/<check>` and `review/<reviewer>`.
315
+
316
+ ```bash
317
+ cbx github setup OWNER/REPO # token limited to this project, secret, workflow file
318
+ cbx submit # the workflow has to be saved into the project
319
+ cbx github sync --repo OWNER/REPO
320
+ ```
321
+
322
+ **Ask before `setup`**: it creates a token and stores it in GitHub. The person
323
+ adds the second secret, `CODEROOK_BRIDGE_PUSH` (a GitHub fine-grained token
324
+ with Contents and Pull requests read/write), themselves, because it is their
325
+ GitHub credential. Every proposal also carries a `coderook/malware` check; a
326
+ failure names the file. Do not try to get round it.
327
+
309
328
  ## Mod repositories for games
310
329
 
311
330
  A game's maker can run a mod repository: players' game clients read a signed