graftfork 0.1.0 → 0.3.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/README.md +20 -1
- package/graft.mjs +356 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,26 @@ graft connect you/project # connect a repository Graft's GitHub app is i
|
|
|
15
15
|
graft sync # start a sync now (inside the clone, or pass the copy)
|
|
16
16
|
graft why # why the copy stayed, and what to do next
|
|
17
17
|
graft pull # fetch the latest move as graft/<release>; your branch is untouched
|
|
18
|
+
graft upstream "fix the parser" # Graft builds that one change alone on your release and runs your tests;
|
|
19
|
+
# this fetches graft/upstream/<change>. Nothing is pushed: you open the PR
|
|
20
|
+
graft upstream-status <build> # an upstream build's status, tests and links
|
|
21
|
+
graft upstream-pr <build> <url> # the pull request you opened; Graft tells you when it's merged
|
|
22
|
+
graft upgrade <dependency> # upgrade one dependency (npm or Python), delivered only if the tests pass
|
|
23
|
+
graft upgrades # your upgrades and what their tests said
|
|
24
|
+
graft check <base> <head> --wait # check a change against the project's own tests: exit 0 pass, 1 fail, 2 couldn't run
|
|
25
|
+
graft checks # your recent change checks
|
|
26
|
+
graft verify-record <file> # check a signed run record or agent change record; no token needed
|
|
27
|
+
graft agent-record <org> # an organization's signed agent change record, as JSON
|
|
28
|
+
graft regen connect <repo> ... # connect a spec (a duramen record); Graft rebuilds its implementation blind
|
|
29
|
+
graft regen run <spec> # rebuild it now
|
|
30
|
+
graft regen status [id] # your specs, a spec's rebuilds, or one rebuild and how to take it
|
|
31
|
+
graft regen report <spec> # the spec's grade and why, before anything is spent
|
|
18
32
|
```
|
|
19
33
|
|
|
20
|
-
Also `graft whoami`, `graft status` and `graft usage`; `--json` prints the API's answers as they are
|
|
34
|
+
Also `graft whoami`, `graft status` and `graft usage`; `--json` prints the API's answers as they are, and
|
|
35
|
+
`graft --help` lists every command with its options.
|
|
36
|
+
|
|
37
|
+
Which version has what: 0.1.0 had copies, connect, sync, status, why, pull, usage, whoami and verify-record; 0.1.1
|
|
38
|
+
added agent-record; 0.2.0 added check(s), upgrade(s) and upstream, upstream-status and upstream-pr; 0.3.0 adds
|
|
39
|
+
regen connect, run, status and report. API docs:
|
|
21
40
|
https://graftfork.com/docs/api. Support by email: support@graftfork.com. Apache-2.0.
|
package/graft.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/client/cli.ts
|
|
4
|
-
import { spawnSync as
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
4
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
|
|
8
8
|
// src/pull.ts
|
|
@@ -30,7 +30,7 @@ async function pull(o) {
|
|
|
30
30
|
let id = o.copy && /^[A-Za-z0-9_-]{8,64}$/.test(o.copy) && !o.copy.includes("/") ? o.copy : null;
|
|
31
31
|
if (!id) {
|
|
32
32
|
const repo = o.copy ?? repoOfRemote(git(o.cwd, ["remote", "get-url", "origin"]).stdout ?? "");
|
|
33
|
-
if (!repo) return { ok: false, message: "Couldn't tell which copy this is:
|
|
33
|
+
if (!repo) return { ok: false, message: "Couldn't tell which copy this is: give its id or owner/name (graft pull <copy>, or --copy <copy>), or run this inside its clone." };
|
|
34
34
|
const { copies } = await get("/api/v1/copies");
|
|
35
35
|
const c = copies.find((x) => String(x.repo).toLowerCase() === repo.toLowerCase());
|
|
36
36
|
if (!c) return { ok: false, message: `${repo} isn't one of your copies on Graft.` };
|
|
@@ -53,7 +53,7 @@ async function pull(o) {
|
|
|
53
53
|
rmSync(dir, { recursive: true, force: true });
|
|
54
54
|
}
|
|
55
55
|
const sha = git(o.cwd, ["rev-parse", "--short", t.branch]).stdout.trim();
|
|
56
|
-
return { ok: true, branch: t.branch, message: `Fetched ${t.branch} (${sha}): your changes on ${t.release}. Your branch is untouched.
|
|
56
|
+
return { ok: true, branch: t.branch, message: `Fetched ${t.branch} (${sha}): ${t.release ? `your changes on ${t.release}` : "the branch Graft delivered"}. Your branch is untouched.
|
|
57
57
|
Switch with: git switch ${t.branch}
|
|
58
58
|
Or take it into ${t.copy.branch}: git checkout ${t.copy.branch} && git merge --ff-only ${t.branch}` };
|
|
59
59
|
} catch (e) {
|
|
@@ -61,6 +61,69 @@ Or take it into ${t.copy.branch}: git checkout ${t.copy.branch} && git merge --f
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// src/client/upstream.ts
|
|
65
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
66
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
67
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
68
|
+
import { join as join2 } from "node:path";
|
|
69
|
+
var git2 = (cwd, args) => spawnSync2("git", args, { cwd, encoding: "utf8" });
|
|
70
|
+
async function upstream(o) {
|
|
71
|
+
const started = await o.call("POST", `/api/v1/copies/${o.copyId}/upstream`, { change: o.change });
|
|
72
|
+
let u = started.upstream;
|
|
73
|
+
if (o.noWait) return { ok: true, message: `${started.message ?? "Building."}
|
|
74
|
+
Build: ${u.id}. Check it with graft upstream-status ${u.id}.` };
|
|
75
|
+
const until = Date.now() + (o.waitMs ?? 30 * 6e4);
|
|
76
|
+
while (u.status === "queued" || u.status === "running") {
|
|
77
|
+
if (Date.now() > until) return { ok: false, message: `Still building after ${Math.round((o.waitMs ?? 30 * 6e4) / 6e4)} minutes. Check it later with graft upstream-status ${u.id}.` };
|
|
78
|
+
await new Promise((r) => setTimeout(r, o.pollMs ?? 1e4));
|
|
79
|
+
u = (await o.call("GET", `/api/v1/upstreams/${u.id}`)).upstream;
|
|
80
|
+
}
|
|
81
|
+
if (u.status !== "built") return { ok: false, message: `Not built: ${u.note ?? "the build failed"}` };
|
|
82
|
+
return take(o, u);
|
|
83
|
+
}
|
|
84
|
+
async function take(o, u) {
|
|
85
|
+
if (!u.downloads?.bundle) return { ok: false, message: `Built ${u.branch}, but Graft doesn't keep it any more. Build it again with graft upstream.` };
|
|
86
|
+
if (git2(o.cwd, ["rev-parse", "--git-dir"]).status !== 0) return { ok: false, message: `Built ${u.branch}. Run this inside your clone of the copy to fetch it, or download it: ${u.downloads.bundle}` };
|
|
87
|
+
const r = await o.fetchImpl(u.downloads.bundle);
|
|
88
|
+
if (!r.ok) return { ok: false, message: `Downloading the branch failed (${r.status}). Try again; graft upstream-status makes a fresh link.` };
|
|
89
|
+
const dir = mkdtempSync2(join2(tmpdir2(), "graft-upstream-"));
|
|
90
|
+
const file = join2(dir, "upstream.bundle");
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync2(file, new Uint8Array(await r.arrayBuffer()));
|
|
93
|
+
const v = git2(o.cwd, ["bundle", "verify", "-q", file]);
|
|
94
|
+
if (v.status !== 0) return { ok: false, message: `This clone is missing what the branch builds on (${v.stderr.trim().split("\n").pop()}). Bring your copy's branch up to date, then run it again.` };
|
|
95
|
+
const f = git2(o.cwd, ["fetch", "-q", file, `+refs/heads/${u.branch}:refs/heads/${u.branch}`]);
|
|
96
|
+
if (f.status !== 0) return { ok: false, message: `git fetch failed: ${f.stderr.trim().split("\n").pop()}` };
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
let saved = "";
|
|
101
|
+
if (o.patchDir && u.downloads.patch) {
|
|
102
|
+
const p = await o.fetchImpl(u.downloads.patch);
|
|
103
|
+
if (p.ok) {
|
|
104
|
+
const path = join2(o.patchDir, `${String(u.branch).split("/").pop()}.patch`);
|
|
105
|
+
writeFileSync2(path, await p.text());
|
|
106
|
+
saved = `
|
|
107
|
+
The patch, for projects that take them by email: ${path}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const t = u.tests ?? {};
|
|
111
|
+
const pr = u.pullRequest ?? {};
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
branch: u.branch,
|
|
115
|
+
message: [
|
|
116
|
+
`Fetched ${u.branch}: only "${u.change?.subject}" on ${u.release}${t.ran ? `; your tests: ${t.verdict ?? "ran"}` : "; no tests ran"}.${saved}`,
|
|
117
|
+
"Nothing was pushed. To send it, from your account:",
|
|
118
|
+
...(u.commands ?? []).slice(2).map((c) => ` ${c}`),
|
|
119
|
+
`Pull request title: ${pr.title}`,
|
|
120
|
+
"Pull request text:",
|
|
121
|
+
...String(pr.body ?? "").split("\n").map((l) => ` ${l}`),
|
|
122
|
+
`Then: graft upstream-pr ${u.id} <the pull request's URL>, and Graft tells you when it's merged.`
|
|
123
|
+
].join("\n")
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
64
127
|
// src/client/cli.ts
|
|
65
128
|
var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run on graftfork.com)
|
|
66
129
|
|
|
@@ -69,11 +132,31 @@ var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run
|
|
|
69
132
|
graft connect <owner/name> connect a repository (Graft's GitHub app must be installed on it)
|
|
70
133
|
[--branch <b> --upstream <owner/name or URL> --base <release tag>] [--tags <pattern>]
|
|
71
134
|
graft sync [copy] start a sync now
|
|
135
|
+
[--force] again, though the last sync of this release stopped at the agent's per-sync budget
|
|
72
136
|
graft status [copy] a copy and its latest runs
|
|
73
137
|
graft why [copy] why the copy stayed, and what to do next
|
|
74
138
|
graft pull [copy] fetch the latest move as graft/<release> in your clone
|
|
139
|
+
graft upgrade <dependency> [version] [--copy <copy>]
|
|
140
|
+
upgrade a dependency (npm or Python), delivered only if the tests pass
|
|
141
|
+
graft upgrades [id] your upgrades, or one with what its tests said
|
|
142
|
+
graft upstream <change> [copy] build one change alone on your release, tested; fetch graft/upstream/<change>
|
|
143
|
+
[--patch <dir>] [--no-wait] (nothing is pushed: you open the pull request yourself)
|
|
144
|
+
graft upstream-status <build> an upstream build's status, tests and links
|
|
145
|
+
graft upstream-pr <build> <url> the pull request you opened upstream; Graft checks it until it's merged
|
|
75
146
|
graft usage this month's syncs and agent work
|
|
76
|
-
graft verify-record <file> check a run's signed record (from /app or GET /api/v1/runs/{id}/record)
|
|
147
|
+
graft verify-record <file> check a run's signed record (from /app or GET /api/v1/runs/{id}/record),
|
|
148
|
+
a rebuild's, or an organization's agent change record
|
|
149
|
+
graft verify-record <id> fetch a rebuild's (or a sync's) signed record by its id, with the token, and check it
|
|
150
|
+
graft agent-record <org> an organization's signed agent change record, as JSON
|
|
151
|
+
[--from <YYYY-MM-DD> --to <YYYY-MM-DD>] (save it: graft agent-record <org> > record.json)
|
|
152
|
+
graft check <base> <head> check a change against the project's own tests (no push, no agent)
|
|
153
|
+
[--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]
|
|
154
|
+
graft checks [id] your recent change checks, or one check's result
|
|
155
|
+
graft regen connect <owner/name or URL> --branch <b> --record <path> --impl <dir> --lang ts|py|rs|go [--builds 1-3] [--on-push]
|
|
156
|
+
connect a spec (a duramen record): Graft rebuilds its implementation blind
|
|
157
|
+
graft regen run <spec> rebuild it now [--force: even if nothing changed] [--max-usd <n>: its budget]
|
|
158
|
+
graft regen status [id] your specs, a spec's rebuilds, or one rebuild and how to take it
|
|
159
|
+
graft regen report <spec> the spec's grade (strong, usable, thin) and why [--refresh [--yes]]
|
|
77
160
|
|
|
78
161
|
[copy] is a copy id or owner/name; left out, it's this clone's origin remote.
|
|
79
162
|
Token: GRAFT_TOKEN or --token (make one at https://graftfork.com/app#settings). --json prints raw answers.`;
|
|
@@ -84,16 +167,16 @@ async function run(argv, io) {
|
|
|
84
167
|
args: argv,
|
|
85
168
|
allowPositionals: true,
|
|
86
169
|
strict: false,
|
|
87
|
-
options: { token: { type: "string" }, api: { type: "string" }, json: { type: "boolean" }, branch: { type: "string" }, upstream: { type: "string" }, base: { type: "string" }, tags: { type: "string" }, help: { type: "boolean", short: "h" } }
|
|
170
|
+
options: { token: { type: "string" }, api: { type: "string" }, json: { type: "boolean" }, patch: { type: "string" }, "no-wait": { type: "boolean" }, branch: { type: "string" }, upstream: { type: "string" }, base: { type: "string" }, tags: { type: "string" }, from: { type: "string" }, to: { type: "string" }, repo: { type: "string" }, "clone-url": { type: "string" }, pr: { type: "string" }, wait: { type: "boolean" }, copy: { type: "string" }, record: { type: "string" }, impl: { type: "string" }, lang: { type: "string" }, builds: { type: "string" }, "on-push": { type: "boolean" }, refresh: { type: "boolean" }, force: { type: "boolean" }, "max-usd": { type: "string" }, yes: { type: "boolean" }, help: { type: "boolean", short: "h" } }
|
|
88
171
|
});
|
|
89
|
-
const [cmd, arg] = positionals;
|
|
172
|
+
const [cmd, arg, arg2] = positionals;
|
|
90
173
|
if (!cmd || a.help || cmd === "help") {
|
|
91
174
|
io.out(USAGE);
|
|
92
175
|
return cmd || a.help ? 0 : 1;
|
|
93
176
|
}
|
|
94
177
|
const api = String(a.api ?? io.env.GRAFT_API ?? "https://graftfork.com").replace(/\/+$/, "");
|
|
95
|
-
if (cmd === "verify-record") return verify(arg, api, io);
|
|
96
178
|
const token = a.token ?? io.env.GRAFT_TOKEN;
|
|
179
|
+
if (cmd === "verify-record") return verify(arg, api, io, token);
|
|
97
180
|
if (!token) {
|
|
98
181
|
io.out("graft needs a personal API token: make one at https://graftfork.com/app#settings, then set GRAFT_TOKEN or pass --token.");
|
|
99
182
|
return 1;
|
|
@@ -107,7 +190,7 @@ async function run(argv, io) {
|
|
|
107
190
|
const show = (b, text) => io.out(a.json ? JSON.stringify(b, null, 1) : text);
|
|
108
191
|
const copyId = async (given) => {
|
|
109
192
|
if (given && !given.includes("/") && /^[A-Za-z0-9_-]{8,64}$/.test(given)) return given;
|
|
110
|
-
const repo = given ?? repoOfRemote(
|
|
193
|
+
const repo = given ?? repoOfRemote(spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "");
|
|
111
194
|
if (!repo) throw new ApiError("Couldn't tell which copy: give its id or owner/name, or run this inside its clone.");
|
|
112
195
|
const { copies } = await call("GET", "/api/v1/copies");
|
|
113
196
|
const c = copies.find((x) => String(x.repo).toLowerCase() === repo.toLowerCase());
|
|
@@ -131,7 +214,7 @@ async function run(argv, io) {
|
|
|
131
214
|
show(b, `${b.message ?? "Connected."}
|
|
132
215
|
Copy id: ${b.copyId}`);
|
|
133
216
|
} else if (cmd === "sync") {
|
|
134
|
-
const b = await call("POST", `/api/v1/copies/${await copyId(arg)}/sync
|
|
217
|
+
const b = await call("POST", `/api/v1/copies/${await copyId(arg)}/sync`, a.force ? { force: true } : void 0);
|
|
135
218
|
show(b, `${b.message}
|
|
136
219
|
Run: ${b.run}`);
|
|
137
220
|
} else if (cmd === "status") {
|
|
@@ -144,12 +227,81 @@ Run: ${b.run}`);
|
|
|
144
227
|
const stuck = (b.stuck ?? []).map((s) => ` ${s.change}: ${s.why}`);
|
|
145
228
|
show(b, [b.headline, ...stuck.length ? ["Stuck:", ...stuck] : [], ...b.action ? [`What to do: ${b.action}`] : [], ...b.command ? [` ${b.command}`] : [], `Next: ${b.next?.why ?? ""}`].filter(Boolean).join("\n"));
|
|
146
229
|
} else if (cmd === "pull") {
|
|
147
|
-
const r = await pull({ cwd: io.cwd, copy: arg, token, api, fetchImpl: io.fetch });
|
|
230
|
+
const r = await pull({ cwd: io.cwd, copy: arg ?? a.copy, token, api, fetchImpl: io.fetch });
|
|
231
|
+
io.out(r.message);
|
|
232
|
+
return r.ok ? 0 : 1;
|
|
233
|
+
} else if (cmd === "agent-record") {
|
|
234
|
+
if (!arg) {
|
|
235
|
+
io.out("graft agent-record <organization id> [--from <YYYY-MM-DD> --to <YYYY-MM-DD>]");
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
const q = new URLSearchParams({ ...a.from && { from: String(a.from) }, ...a.to && { to: String(a.to) } }).toString();
|
|
239
|
+
const b = await call("GET", `/api/v1/orgs/${encodeURIComponent(arg)}/agent-record${q ? `?${q}` : ""}`);
|
|
240
|
+
io.out(JSON.stringify(b, null, 1));
|
|
241
|
+
} else if (cmd === "check") {
|
|
242
|
+
return await check(positionals[1], positionals[2]);
|
|
243
|
+
} else if (cmd === "checks") {
|
|
244
|
+
if (arg) {
|
|
245
|
+
const b = await call("GET", `/api/v1/checks/${encodeURIComponent(arg)}`);
|
|
246
|
+
show(b, checkText(b.check));
|
|
247
|
+
} else {
|
|
248
|
+
const b = await call("GET", "/api/v1/checks");
|
|
249
|
+
const rows = b.checks.map((c) => `${c.id} ${c.repo} ${c.head} vs ${c.base}${c.pr ? ` (#${c.pr})` : ""} ${c.outcome ?? c.status}`);
|
|
250
|
+
show(b, [...rows.length ? rows : ["No change checks yet. Start one with: graft check <base> <head>"], ...b.usage ? [`${b.usage.used} of ${b.usage.limit} checks this month.`] : []].join("\n"));
|
|
251
|
+
}
|
|
252
|
+
} else if (cmd === "upgrade") {
|
|
253
|
+
if (!arg) {
|
|
254
|
+
io.out("graft upgrade <dependency> [version] [--copy <copy id or owner/name>]");
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
const b = await call("POST", "/api/v1/upgrades", { copy_id: await copyId(a.copy), dependency: arg, ...positionals[2] && { version: positionals[2] } });
|
|
258
|
+
show(b, `${b.message}
|
|
259
|
+
Upgrade: ${b.upgrade} (graft upgrades ${b.upgrade})`);
|
|
260
|
+
} else if (cmd === "upgrades") {
|
|
261
|
+
if (arg) {
|
|
262
|
+
const { upgrade: u } = await call("GET", `/api/v1/upgrades/${encodeURIComponent(arg)}`);
|
|
263
|
+
const lines = [`${u.dependency} ${u.from ?? ""} to ${u.version ?? u.requested} on ${u.repo ?? "?"}: ${u.status}`, ...u.why ? [` ${u.why}`] : []];
|
|
264
|
+
if (u.tests?.newFailures?.length) lines.push("Broken by the upgrade:", ...u.tests.newFailures.map((f) => ` ${f}`));
|
|
265
|
+
if (u.fix) lines.push(`Graft's agent: ${u.fix.applied ? `changed ${(u.fix.files ?? []).join(", ")}` : `not used (${u.fix.why})`}`);
|
|
266
|
+
if (u.delivered) lines.push(`Delivered as ${u.delivered.branch}. Take it in your clone with: graft pull ${u.run}`);
|
|
267
|
+
if (u.prUrl) lines.push(`Pull request: ${u.prUrl}`);
|
|
268
|
+
show({ upgrade: u }, lines.join("\n"));
|
|
269
|
+
} else {
|
|
270
|
+
const b = await call("GET", "/api/v1/upgrades");
|
|
271
|
+
const rows = b.upgrades.map((u) => `${u.id} ${u.repo ?? "?"} ${u.dependency} to ${u.version ?? u.requested}: ${u.status}`);
|
|
272
|
+
show(b, rows.length ? rows.join("\n") : "No upgrades yet. Start one with: graft upgrade <dependency> [version]");
|
|
273
|
+
}
|
|
274
|
+
} else if (cmd === "upstream") {
|
|
275
|
+
if (!arg) {
|
|
276
|
+
io.out("graft upstream <change> [copy] [--patch <dir>] [--no-wait]: the change's subject (or part of it), or its number in the latest move");
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
const r = await upstream({ cwd: io.cwd, copyId: await copyId(arg2), change: arg, call, fetchImpl: io.fetch, noWait: !!a["no-wait"], ...a.patch && { patchDir: String(a.patch) }, ...io.env.GRAFT_POLL_MS && { pollMs: Number(io.env.GRAFT_POLL_MS) } });
|
|
148
280
|
io.out(r.message);
|
|
149
281
|
return r.ok ? 0 : 1;
|
|
282
|
+
} else if (cmd === "upstream-status") {
|
|
283
|
+
if (!arg) {
|
|
284
|
+
io.out("graft upstream-status <build>");
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
const b = await call("GET", `/api/v1/upstreams/${encodeURIComponent(arg)}`);
|
|
288
|
+
const u = b.upstream;
|
|
289
|
+
const pr = u.pullRequest ?? {};
|
|
290
|
+
show(b, [`${u.change?.subject} on ${u.release}: ${u.status}${u.phase ? ` (${u.phase})` : ""}${u.note ? `. ${u.note}` : ""}`, ...(u.commands ?? []).map((c) => ` ${c}`), ...pr.url ? [`Pull request: ${pr.url}${pr.state ? ` (${pr.state})` : ""}`] : []].join("\n"));
|
|
291
|
+
} else if (cmd === "upstream-pr") {
|
|
292
|
+
if (!arg || !arg2) {
|
|
293
|
+
io.out("graft upstream-pr <build> <https://github.com/owner/name/pull/123>");
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
const b = await call("POST", `/api/v1/upstreams/${encodeURIComponent(arg)}/pr`, { url: arg2 });
|
|
297
|
+
show(b, b.message);
|
|
298
|
+
} else if (cmd === "regen") {
|
|
299
|
+
return await regen(arg, arg2, a, call, show, io);
|
|
150
300
|
} else if (cmd === "usage") {
|
|
151
301
|
const b = await call("GET", "/api/v1/usage");
|
|
152
|
-
|
|
302
|
+
const orgs = Array.isArray(b.coveredBy) ? b.coveredBy : [];
|
|
303
|
+
const covered = orgs.length ? ` Copies in ${orgs.map((o) => `${o.name} (${o.plan})`).join(", ")} work as Premium: up to ${b.syncs?.coveredSyncs ?? "?"} syncs this month.` : "";
|
|
304
|
+
show(b, `${b.plan}${orgs.length ? " (your own)" : ""}: ${b.syncs?.used ?? 0} of ${b.syncs?.limit ?? "?"} syncs this month; Graft's agent $${Number(b.graftAgentUsd ?? 0).toFixed(2)}.${covered} Resets ${b.resetsOn}.`);
|
|
153
305
|
} else {
|
|
154
306
|
io.out(USAGE);
|
|
155
307
|
return 1;
|
|
@@ -159,19 +311,183 @@ Run: ${b.run}`);
|
|
|
159
311
|
io.out(e instanceof ApiError ? e.message : `graft: ${e.message}`);
|
|
160
312
|
return 1;
|
|
161
313
|
}
|
|
314
|
+
async function check(base, head) {
|
|
315
|
+
const pr = a.pr === void 0 ? void 0 : Number(a.pr);
|
|
316
|
+
if ((!base || !head) && pr === void 0) {
|
|
317
|
+
io.out("graft check <base> <head> [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]");
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
let where;
|
|
321
|
+
if (a.repo) where = { repo: a.repo };
|
|
322
|
+
else if (a["clone-url"]) where = { clone_url: a["clone-url"] };
|
|
323
|
+
else {
|
|
324
|
+
const remote = (spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "").trim();
|
|
325
|
+
const repo = repoOfRemote(remote);
|
|
326
|
+
if (/github\.com[:/]/.test(remote) && repo) where = { repo };
|
|
327
|
+
else if (/^https:\/\//.test(remote) && !/@/.test(remote.split("/")[2] ?? "")) where = { clone_url: remote };
|
|
328
|
+
else {
|
|
329
|
+
io.out("Couldn't tell which repository: pass --repo <owner/name> or --clone-url <url>, or run this inside its clone.");
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const b = await call("POST", "/api/v1/checks", { ...where, ...base && { base }, ...head && { head }, ...pr !== void 0 && { pr } });
|
|
334
|
+
if (!a.wait) {
|
|
335
|
+
show(b, `${b.message}
|
|
336
|
+
Check: ${b.check} (graft checks ${b.check})`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
340
|
+
for (let i = 0; i < 200; i++) {
|
|
341
|
+
const c = (await call("GET", `/api/v1/checks/${encodeURIComponent(b.check)}`)).check;
|
|
342
|
+
if (!["queued", "running"].includes(c.status)) {
|
|
343
|
+
show({ check: c }, checkText(c));
|
|
344
|
+
return c.outcome === "pass" ? 0 : c.outcome === "fail" ? 1 : 2;
|
|
345
|
+
}
|
|
346
|
+
await sleep(15e3);
|
|
347
|
+
}
|
|
348
|
+
io.out(`Still running. Read it later with: graft checks ${b.check}`);
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function checkText(c) {
|
|
353
|
+
const word = c.outcome === "pass" ? "PASS" : c.outcome === "fail" ? "FAIL" : c.outcome === "could_not_run" ? "COULD NOT RUN" : String(c.status).toUpperCase();
|
|
354
|
+
const sha = (r) => r?.sha ? ` (${String(r.sha).slice(0, 7)})` : "";
|
|
355
|
+
return [
|
|
356
|
+
`${word}: ${c.repo} ${c.head}${sha(c.headRun)} against ${c.base}${sha(c.baseRun)}${c.pr ? `, pull request #${c.pr}` : ""}`,
|
|
357
|
+
...c.reason ? [c.reason] : [],
|
|
358
|
+
...(c.newFailures ?? []).length ? ["New failures:", ...c.newFailures.map((f) => ` ${f}`)] : [],
|
|
359
|
+
...(c.alreadyFailing ?? []).length ? [`${c.alreadyFailing.length} already failing on the base (not counted against the change).`] : [],
|
|
360
|
+
...(c.testFilesChanged ?? []).length ? [`The change edits files that shape the tests: ${c.testFilesChanged.join(", ")}. Read them before trusting the result.`] : []
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
async function regen(sub, id, a, call, show, io) {
|
|
364
|
+
const line = (r) => `${r.id} ${r.status === "stayed" ? "not kept" : r.status}${r.phase ? ` (${r.phase})` : ""}${r.suite?.total ? ` suite ${r.suite.passed}/${r.suite.total}` : ""}${r.choicesDiffer ? ` ${r.choicesDiffer} choice(s) differ` : ""}${r.why ? `: ${r.why}` : ""}`;
|
|
365
|
+
if (sub === "connect") {
|
|
366
|
+
if (!id || !a.record || !a.impl || !a.lang || !a.branch) {
|
|
367
|
+
io.out("graft regen connect <owner/name or https URL> --branch <b> --record <path> --impl <dir> --lang ts|py|rs|go [--builds 1-3] [--on-push]");
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
const b = await call("POST", "/api/v1/regen", { .../^https:\/\//.test(id) ? { clone_url: id } : { repo: id }, branch: a.branch, record: a.record, impl: a.impl, lang: a.lang, ...a.builds && { builds: Number(a.builds) }, ...a["on-push"] && { on_push: true } });
|
|
371
|
+
show(b, `${b.message ?? "Connected."}
|
|
372
|
+
Spec id: ${b.id}`);
|
|
373
|
+
} else if (sub === "run") {
|
|
374
|
+
if (!id) {
|
|
375
|
+
io.out("graft regen run <spec id> [--force] [--max-usd <n>]");
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
const max = a["max-usd"] === void 0 ? void 0 : Number(a["max-usd"]);
|
|
379
|
+
if (max !== void 0 && !(Number.isFinite(max) && max >= 0)) {
|
|
380
|
+
io.out("--max-usd is a number of dollars, 0 or more.");
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
const body = { ...a.force && { force: true }, ...max !== void 0 && { max_usd: max } };
|
|
384
|
+
const b = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/run`, Object.keys(body).length ? body : void 0).catch((e) => {
|
|
385
|
+
throw e instanceof ApiError ? new ApiError(e.message.replace(/\bmax_usd\b/g, "--max-usd")) : e;
|
|
386
|
+
});
|
|
387
|
+
show(b, `${b.message}
|
|
388
|
+
Rebuild: ${b.run}`);
|
|
389
|
+
} else if (sub === "status") {
|
|
390
|
+
if (!id) {
|
|
391
|
+
const b = await call("GET", "/api/v1/regen");
|
|
392
|
+
const rows = b.specs.map((s) => `${s.id} ${s.repo} ${s.branch}: ${s.record} -> ${s.impl} (${s.lang})${s.last ? `
|
|
393
|
+
last: ${line(s.last)}` : "\n never rebuilt"}`);
|
|
394
|
+
show(b, rows.length ? rows.join("\n") : b.available ? "No specs yet. Connect one with: graft regen connect <owner/name> --branch main --record spec/ --impl impl --lang ts" : "Rebuilding from a spec is on Premium, Team and Enterprise.");
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
const spec = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}`).catch(() => null);
|
|
398
|
+
if (spec) {
|
|
399
|
+
show(spec, [`${spec.spec.repo} ${spec.spec.branch}: ${spec.spec.record} -> ${spec.spec.impl} (${spec.spec.lang})`, ...spec.runs.length ? spec.runs.map((r) => ` ${line(r)}`) : [" No rebuilds yet."]].join("\n"));
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
const { run: run2 } = await call("GET", `/api/v1/regen/runs/${encodeURIComponent(id)}`);
|
|
403
|
+
const differ = run2.choicesDiff?.previous;
|
|
404
|
+
const lines = [line(run2), ...run2.builds.map((b) => ` build ${b.n}: ${b.verdict?.why ?? b.note ?? b.status}`)];
|
|
405
|
+
if (differ) for (const [k, label] of [["changed", "chosen differently"], ["added", "new"], ["removed", "gone"]]) for (const c of differ[k]) lines.push(` ${label}: ${c.title}`);
|
|
406
|
+
if (run2.doctor?.gaps?.length) {
|
|
407
|
+
const say = (g) => g.kind === "failing" ? `${g.ref}: cases ${g.cases.join(", ")} fail${g.why ? ` (${g.why})` : ""}` : g.kind === "builds differ" ? `${g.ref ?? g.title}: the builds chose differently` : g.kind === "changed since" ? `${g.ref ?? g.title}: chosen differently from the last passing rebuild` : g.kind === "build crashed" ? `${g.build ? `build ${g.build}` : "a build"} crashed: ${g.why}` : g.kind === "hidden request" ? `build ${g.build} answered ${g.op} ${g.input} differently from the oracle` : `the oracle can't answer ${g.op} ${g.input}: ${g.error}`;
|
|
408
|
+
lines.push(run2.doctor.summary, ...run2.doctor.gaps.slice(0, 8).map((g) => ` - ${say(g)}`), ...run2.doctor.gaps.length > 8 ? [` and ${run2.doctor.gaps.length - 8} more (--json)`] : []);
|
|
409
|
+
}
|
|
410
|
+
if (run2.take) lines.push("Take it, in your clone:", ...run2.take.commands.map((c) => ` ${c}`));
|
|
411
|
+
show({ run: run2 }, lines.join("\n"));
|
|
412
|
+
} else if (sub === "report") {
|
|
413
|
+
if (!id) {
|
|
414
|
+
io.out("graft regen report <spec id> [--refresh]");
|
|
415
|
+
return 1;
|
|
416
|
+
}
|
|
417
|
+
if (a.refresh) {
|
|
418
|
+
const before = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}/report`);
|
|
419
|
+
const price = Number(before.nextReport?.priceUsd ?? 0);
|
|
420
|
+
if (before.nextReport?.available === false) {
|
|
421
|
+
io.out(before.nextReport.why);
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
424
|
+
if (price > 0 && !a.yes) {
|
|
425
|
+
io.out(`A new report costs $${price.toFixed(2)} from your agent work (today's free ones are used). Run again with --yes to pay for it.`);
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
428
|
+
const q = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/report`, { max_usd: price });
|
|
429
|
+
show(q, `${q.message}
|
|
430
|
+
Report run: ${q.run}`);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
const b = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}/report`);
|
|
434
|
+
const r = b.report;
|
|
435
|
+
if (!r) {
|
|
436
|
+
show(b, b.pending ? `A report is on its way (${b.pending}).` : b.message ?? "No report yet: graft regen report <spec id> --refresh");
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
439
|
+
const n = r.numbers ?? {};
|
|
440
|
+
const m = r.mutation ?? {};
|
|
441
|
+
show(b, [
|
|
442
|
+
`${String(r.grade).toUpperCase()}${r.commit ? ` (at ${String(r.commit).slice(0, 12)})` : ""}`,
|
|
443
|
+
...r.reasons.map((x) => ` - ${x}`),
|
|
444
|
+
`${n.requirements ?? "?"} requirements, ${n.examples ?? "?"} examples, ${n.decisions ?? "?"} decisions, ${n.open ?? "?"} open; ${n.oracle ? "an oracle" : "no oracle"}`,
|
|
445
|
+
m.ran ? `mistakes planted in the oracle: ${m.killed} caught, ${m.unpinned} unnoticed, ${m.silent} change nothing (of ${m.total})` : `no mutation score: ${m.why}`,
|
|
446
|
+
`fit: ${r.fit?.why ?? ""}`,
|
|
447
|
+
...b.pending ? [`A newer report is on its way (${b.pending}).`] : []
|
|
448
|
+
].join("\n"));
|
|
449
|
+
} else {
|
|
450
|
+
io.out("graft regen connect|run|status|report (graft help)");
|
|
451
|
+
return 1;
|
|
452
|
+
}
|
|
453
|
+
return 0;
|
|
162
454
|
}
|
|
163
455
|
var unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
164
|
-
async function verify(file, api, io) {
|
|
456
|
+
async function verify(file, api, io, token) {
|
|
165
457
|
if (!file) {
|
|
166
|
-
io.out("graft verify-record <file>");
|
|
458
|
+
io.out("graft verify-record <file or rebuild id>");
|
|
167
459
|
return 1;
|
|
168
460
|
}
|
|
169
461
|
let e;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
462
|
+
if (!existsSync(file) && /^[A-Za-z0-9_-]{8,64}$/.test(file)) {
|
|
463
|
+
if (!token) {
|
|
464
|
+
io.out(`${file} isn't a file. To fetch a record by its id, graft needs a personal API token: make one at https://graftfork.com/app#settings, then set GRAFT_TOKEN or pass --token.`);
|
|
465
|
+
return 1;
|
|
466
|
+
}
|
|
467
|
+
let got = null;
|
|
468
|
+
for (const path of [`/api/v1/regen/runs/${file}/record`, `/api/v1/runs/${file}/record`]) {
|
|
469
|
+
const r2 = await io.fetch(`${api}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
470
|
+
if (r2.ok) {
|
|
471
|
+
got = await r2.json().catch(() => null);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
if (r2.status !== 404) {
|
|
475
|
+
io.out(`graftfork.com answered ${r2.status} for ${file}'s record.`);
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!got) {
|
|
480
|
+
io.out(`No signed record for ${file}: not one of your rebuilds or syncs (or a public spec's rebuild), or it hasn't finished.`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
e = got;
|
|
484
|
+
} else {
|
|
485
|
+
try {
|
|
486
|
+
e = JSON.parse(readFileSync(file, "utf8"));
|
|
487
|
+
} catch {
|
|
488
|
+
io.out(`${file} isn't a Graft record (not JSON).`);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
175
491
|
}
|
|
176
492
|
if (e?.graft_record !== 1 || e.alg !== "Ed25519" || typeof e.payload !== "string" || typeof e.signature !== "string") {
|
|
177
493
|
io.out(`${file} isn't a Graft record.`);
|
|
@@ -194,7 +510,27 @@ async function verify(file, api, io) {
|
|
|
194
510
|
return 1;
|
|
195
511
|
}
|
|
196
512
|
const r = JSON.parse(e.payload);
|
|
197
|
-
|
|
513
|
+
if (r.graft_agent_record === 1) {
|
|
514
|
+
io.out(`Valid: signed by graftfork.com (key ${k.id}). Agent change record for ${r.org?.name ?? r.org?.id}, ${r.range?.from} to ${r.range?.to}: ${(r.changes ?? []).length} change(s) in ${r.counts?.runs ?? 0} run(s), ${(r.agent_actions ?? []).length} agent action(s).`);
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
const i = r.initiator;
|
|
518
|
+
const by = !i?.kind ? "" : i.kind === "agent" ? ` Started by ${i.agent ? `the organization's agent ${i.agent} (org ${i.org ?? "unknown"}, ` : "an agent ("}token ${i.token ?? "unknown"}${i.client?.name ? `, ${i.client.name}${i.client.version ? ` ${i.client.version}` : ""}` : ""}).` : i.kind === "ops" ? " Started by Graft's operator." : i.kind === "schedule" ? " Started by Graft on its own (a release, a push or a new model)." : " Started by a person.";
|
|
519
|
+
if (r.kind === "regen") {
|
|
520
|
+
const outcome = r.outcome === "passed" ? "kept" : r.outcome === "stayed" ? "not kept" : r.outcome;
|
|
521
|
+
if (r.rejudge?.of) {
|
|
522
|
+
const verdict = r.outcome === "passed" ? "passed" : r.outcome === "stayed" ? "didn't pass" : r.outcome;
|
|
523
|
+
const agree = r.rejudge.agrees === true ? "agrees with" : r.rejudge.agrees === false ? "differs from" : "can't be compared with";
|
|
524
|
+
io.out(`Valid: signed by graftfork.com (key ${k.id}). Re-judge ${r.run} of rebuild ${r.rejudge.of}: ${verdict}${r.suite ? `, suite ${r.suite.passed}/${r.suite.total}` : ""}, holdout seed ${r.rejudge.seed}${r.rejudge.same_seed ? " (the rebuild's own)" : ""}; it ${agree} the first judge.${r.judge ? ` Judged as ${r.judge.driver_user === "separate" ? "a separate user" : "the oracle's user"}, network ${r.judge.network}.` : ""}${by}`);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
const what = r.draft?.branch ? `Draft rebuild ${r.run} of branch ${r.draft.branch}` : `Rebuild ${r.run}`;
|
|
528
|
+
const b = r.behaviour;
|
|
529
|
+
const beh = !b ? "" : b.sampled ? ` Against rebuild ${b.against?.run ?? "?"}: ${b.requests} requests, ${(b.paths ?? []).length} part(s) of the answer changed, ${b.unexplained} change(s) the record didn't ask for.` : ` Not compared with the kept build${b.why ? `: ${b.why}` : ""}.`;
|
|
530
|
+
io.out(`Valid: signed by graftfork.com (key ${k.id}). ${what}, ${outcome}${r.copy?.repo ? `: ${r.copy.repo} ${r.copy.record ?? ""} to ${r.copy.impl ?? ""}` : ""}${r.spec?.name ? ` (${r.spec.name}${r.spec.version ? ` ${r.spec.version}` : ""})` : ""}${r.suite ? `, suite ${r.suite.passed}/${r.suite.total}` : ""}, ${(r.builds ?? []).length} build(s).${beh}${by}`);
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
io.out(`Valid: signed by graftfork.com (key ${k.id}). Run ${r.run}, ${r.outcome}${r.upstream?.to ? ` onto ${r.upstream.to}` : ""}, ${(r.changes ?? []).length} change(s).${by}`);
|
|
198
534
|
return 0;
|
|
199
535
|
}
|
|
200
536
|
if (process.argv[1] && /graft(\.mjs)?$|client[/\\]cli\.ts$/.test(process.argv[1])) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graftfork",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The command line for graftfork.com: connect a copy, sync it, see why it stayed, and take each move. A thin client; syncs run on graftfork.com.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://graftfork.com/docs/api",
|