@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +31 -0
- package/dist/cli/src/api.js +16 -0
- package/dist/cli/src/cli.js +30 -0
- package/dist/cli/src/github_bridge.js +403 -0
- package/package.json +1 -1
- package/skills/coderook/SKILL.md +19 -0
|
@@ -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.
|
|
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:
|
package/dist/cli/src/api.js
CHANGED
|
@@ -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",
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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.
|
|
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",
|
package/skills/coderook/SKILL.md
CHANGED
|
@@ -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
|