bottleship 0.1.0-prototype.0 → 0.1.0-prototype.2

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +118 -113
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -83
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +3 -4
  9. package/dist/src/init.js +120 -205
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/secrets.d.ts +1 -0
  49. package/dist/src/secrets.js +11 -1
  50. package/dist/src/types.d.ts +1 -71
  51. package/dist/src/ui.d.ts +6 -1
  52. package/dist/src/ui.js +22 -23
  53. package/dist/src/util.d.ts +11 -0
  54. package/dist/src/util.js +40 -0
  55. package/package.json +7 -14
  56. package/presets/agents/claude.json +1 -5
  57. package/presets/agents/codex.json +1 -10
  58. package/presets/agents/cursor.json +1 -9
  59. package/presets/agents/opencode.json +1 -14
  60. package/presets/providers/docker/create +12 -0
  61. package/presets/providers/docker/destroy +2 -0
  62. package/presets/providers/docker/exec +2 -0
  63. package/presets/providers/docker/list +4 -0
  64. package/presets/providers/docker/status +2 -0
  65. package/presets/providers/docker/url +4 -0
  66. package/review-app/KIT.md +106 -0
  67. package/review-app/WRITING.md +93 -0
  68. package/review-app/examples/rply-16.tsx +98 -0
  69. package/review-app/index.html +15 -0
  70. package/review-app/package-lock.json +2539 -0
  71. package/review-app/package.json +24 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  93. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  94. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  95. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  96. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  97. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  98. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  99. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  101. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  102. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  103. package/review-app/playground/gallery.tsx +127 -0
  104. package/review-app/playground/index.html +15 -0
  105. package/review-app/playground/main.tsx +26 -0
  106. package/review-app/playground/mock.ts +215 -0
  107. package/review-app/playground/playground.css +18 -0
  108. package/review-app/playground/vite.config.ts +32 -0
  109. package/review-app/scripts/check.cjs +47 -0
  110. package/review-app/scripts/clip.cjs +104 -0
  111. package/review-app/scripts/shot.cjs +66 -0
  112. package/review-app/src/App.tsx +132 -0
  113. package/review-app/src/data/box.json +1 -0
  114. package/review-app/src/data/hooks.diff +28 -0
  115. package/review-app/src/data/pr.diff +114 -0
  116. package/review-app/src/data/pr.json +68 -0
  117. package/review-app/src/data/review-comments.json +1 -0
  118. package/review-app/src/kit/agent.tsx +287 -0
  119. package/review-app/src/kit/chapter.tsx +178 -0
  120. package/review-app/src/kit/code.tsx +217 -0
  121. package/review-app/src/kit/comments.tsx +377 -0
  122. package/review-app/src/kit/finish.tsx +274 -0
  123. package/review-app/src/kit/flow.tsx +184 -0
  124. package/review-app/src/kit/index.ts +10 -0
  125. package/review-app/src/kit/kit.css +696 -0
  126. package/review-app/src/kit/live.ts +91 -0
  127. package/review-app/src/kit/media.tsx +348 -0
  128. package/review-app/src/kit/overview.tsx +161 -0
  129. package/review-app/src/kit/pr.ts +100 -0
  130. package/review-app/src/kit/pullrequest.tsx +298 -0
  131. package/review-app/src/kit/review.ts +74 -0
  132. package/review-app/src/kit/theme.ts +30 -0
  133. package/review-app/src/kit/ui.tsx +127 -0
  134. package/review-app/src/main.tsx +6 -0
  135. package/review-app/src/review.tsx +22 -0
  136. package/review-app/tsconfig.json +17 -0
  137. package/review-app/vite.config.ts +12 -0
  138. package/dist/presets/providers/command.d.ts +0 -3
  139. package/dist/presets/providers/command.js +0 -39
  140. package/dist/presets/providers/daytona.d.ts +0 -3
  141. package/dist/presets/providers/daytona.js +0 -173
  142. package/dist/presets/providers/docker.d.ts +0 -3
  143. package/dist/presets/providers/docker.js +0 -31
  144. package/dist/presets/providers/e2b.d.ts +0 -3
  145. package/dist/presets/providers/e2b.js +0 -124
  146. package/dist/presets/providers/exe.d.ts +0 -3
  147. package/dist/presets/providers/exe.js +0 -28
  148. package/dist/presets/providers/http.example.d.ts +0 -3
  149. package/dist/presets/providers/http.example.js +0 -47
  150. package/dist/presets/providers/local.d.ts +0 -3
  151. package/dist/presets/providers/local.js +0 -52
  152. package/dist/presets/providers/modal.d.ts +0 -3
  153. package/dist/presets/providers/modal.js +0 -117
  154. package/dist/presets/providers/ssh.d.ts +0 -3
  155. package/dist/presets/providers/ssh.js +0 -49
  156. package/dist/presets/providers/util.d.ts +0 -13
  157. package/dist/presets/providers/util.js +0 -102
  158. package/presets/providers/command.ts +0 -43
  159. package/presets/providers/daytona.ts +0 -181
  160. package/presets/providers/docker.ts +0 -38
  161. package/presets/providers/e2b.ts +0 -130
  162. package/presets/providers/exe.ts +0 -35
  163. package/presets/providers/http.example.ts +0 -64
  164. package/presets/providers/local.ts +0 -56
  165. package/presets/providers/modal.ts +0 -121
  166. package/presets/providers/ssh.ts +0 -54
  167. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,81 @@
1
+ // A provider is a directory of bash scripts that run boxes somewhere. Docker ships with bottleship;
2
+ // for any other sandbox an agent writes the same scripts into ~/.bottleship/providers/<name>/.
3
+ //
4
+ // create <id> <dockerfile> <image> build the box from the Dockerfile (cache it as <image>), start it, exit once it runs commands
5
+ // exec <id> <script> run `bash -c <script>` in the box as its user, stdin passed through
6
+ // status <id> print running or gone
7
+ // destroy <id> remove the box and everything in it
8
+ // url <id> <port> (optional) print a URL on this machine for a port the box exposes
9
+ // list (optional) print the boxes bottleship made, one per line: <id> [<created, ISO 8601>]
10
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { HOME, ROOT } from "./paths.js";
14
+ import { BsError } from "./config.js";
15
+ import { run, spawnStream } from "./util.js";
16
+ const SCRIPTS = ["create", "exec", "status", "destroy"];
17
+ const dirs = () => [join(HOME, "providers"), join(ROOT, "presets/providers")];
18
+ // user providers first, so a user can replace the builtin docker scripts
19
+ export function providerNames() {
20
+ const names = dirs().flatMap((d) => (existsSync(d) ? readdirSync(d).filter((n) => existsSync(join(d, n, "create"))) : []));
21
+ return [...new Set(names)];
22
+ }
23
+ export function getProvider(name) {
24
+ const dir = dirs().map((d) => join(d, name)).find((d) => existsSync(d));
25
+ if (!dir)
26
+ throw new BsError("unknown_provider", `no provider "${name}"`, `known: ${providerNames().join(", ")}. A provider is a directory of scripts in ${join(HOME, "providers")}, see "Providers" in ${join(ROOT, "README.md")}`);
27
+ const missing = SCRIPTS.filter((s) => !existsSync(join(dir, s)));
28
+ if (missing.length)
29
+ throw new BsError("bad_provider", `provider "${name}" (${dir}) has no ${missing.join(", ")} script`, `add them, see "Providers" in ${join(ROOT, "README.md")}`);
30
+ const script = (s) => join(dir, s);
31
+ const call = (s, args, stdin) => run("bash", [script(s), ...args], { stdin, check: false });
32
+ const failed = (s, r) => new Error(`${name}/${s} exited ${r.code}: ${(r.stderr || r.stdout).trim().slice(-2000)}`);
33
+ return {
34
+ name,
35
+ async create(id, dockerfile, image) {
36
+ // the Dockerfile goes in a file, since providers build it with their own tools
37
+ const tmp = mkdtempSync(join(tmpdir(), "bs-df-"));
38
+ try {
39
+ writeFileSync(join(tmp, "Dockerfile"), dockerfile);
40
+ const r = await call("create", [id, join(tmp, "Dockerfile"), image]);
41
+ if (r.code !== 0)
42
+ throw failed("create", r);
43
+ }
44
+ finally {
45
+ rmSync(tmp, { recursive: true, force: true });
46
+ }
47
+ },
48
+ exec: (id, s, stdin) => call("exec", [id, s], stdin),
49
+ stream: (id, s) => spawnStream("bash", [script("exec"), id, s]),
50
+ async status(id) {
51
+ const r = await call("status", [id]);
52
+ if (r.code !== 0)
53
+ throw failed("status", r);
54
+ return r.stdout.trim() === "running" ? "running" : "gone";
55
+ },
56
+ async destroy(id) {
57
+ const r = await call("destroy", [id]);
58
+ if (r.code !== 0)
59
+ throw failed("destroy", r);
60
+ },
61
+ ...(existsSync(script("url")) && {
62
+ async url(id, port) {
63
+ const r = await call("url", [id, String(port)]);
64
+ if (r.code !== 0)
65
+ throw failed("url", r);
66
+ return r.stdout.trim();
67
+ },
68
+ }),
69
+ ...(existsSync(script("list")) && {
70
+ async list() {
71
+ const r = await call("list", []);
72
+ if (r.code !== 0)
73
+ throw failed("list", r);
74
+ return r.stdout.split("\n").filter(Boolean).map((l) => {
75
+ const [box, created] = l.trim().split(/\s+/);
76
+ return { box, ...(created && { created }) };
77
+ });
78
+ },
79
+ }),
80
+ };
81
+ }
@@ -0,0 +1,21 @@
1
+ export interface Repo {
2
+ url: string;
3
+ label: string;
4
+ }
5
+ export interface RepoEnv {
6
+ dockerfile?: string;
7
+ ports: number[];
8
+ }
9
+ export declare const git: (cwd: string, ...args: string[]) => string;
10
+ export declare function toRepo(s: string): Repo;
11
+ export declare function checkout(cwd: string): {
12
+ root: string;
13
+ origin?: Repo;
14
+ remote?: string;
15
+ } | undefined;
16
+ export declare function dotenvFiles(root: string): Record<string, string>;
17
+ export declare function readRepoEnv(root: string): RepoEnv | undefined;
18
+ export declare function readEnvFiles(root: string): Record<string, string> | undefined;
19
+ export declare function parseRepoEnv(dockerfile: string | undefined): RepoEnv;
20
+ export declare function workingTree(root: string): Promise<Buffer>;
21
+ export declare function tarFiles(root: string, files: string[]): Promise<Buffer>;
@@ -0,0 +1,75 @@
1
+ // The user's local checkout: its origin, its .env files, its working tree, and its box environment in .bottleship/.
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { BsError } from "./config.js";
6
+ import { run } from "./util.js";
7
+ export const git = (cwd, ...args) => {
8
+ try {
9
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
10
+ }
11
+ catch {
12
+ return "";
13
+ }
14
+ };
15
+ // owner/name or a GitHub URL becomes an https clone URL, so the GH_TOKEN helper applies; anything else is cloned as is
16
+ export function toRepo(s) {
17
+ const gh = s.match(/^([\w.-]+\/[\w.-]+?)(?:\.git)?$/) ?? s.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
18
+ return gh ? { url: `https://github.com/${gh[1]}.git`, label: gh[1] } : { url: s, label: s };
19
+ }
20
+ // the checkout around cwd, if any
21
+ // remote: the origin URL as git has it
22
+ export function checkout(cwd) {
23
+ const root = git(cwd, "rev-parse", "--show-toplevel");
24
+ if (!root)
25
+ return undefined;
26
+ const origin = git(root, "remote", "get-url", "origin");
27
+ return { root, ...(origin && { origin: toRepo(origin), remote: origin }) };
28
+ }
29
+ // .env files at the repo root, minus templates like .env.example
30
+ export function dotenvFiles(root) {
31
+ const names = readdirSync(root).filter((f) => /^\.env/.test(f) && !/\.(example|sample|template)$/.test(f) && statSync(join(root, f)).isFile());
32
+ return Object.fromEntries(names.map((f) => [f, readFileSync(join(root, f), "utf8")]));
33
+ }
34
+ // undefined when the repo has no .bottleship/ yet
35
+ export function readRepoEnv(root) {
36
+ const files = readEnvFiles(root);
37
+ return files && parseRepoEnv(files.Dockerfile);
38
+ }
39
+ // every file in the checkout's .bottleship/, by name
40
+ export function readEnvFiles(root) {
41
+ const dir = join(root, ".bottleship");
42
+ if (!existsSync(dir))
43
+ return undefined;
44
+ const names = readdirSync(dir).filter((f) => statSync(join(dir, f)).isFile());
45
+ return Object.fromEntries(names.map((f) => [f, readFileSync(join(dir, f), "utf8")]));
46
+ }
47
+ // the image lines from .bottleship/Dockerfile and the ports it EXPOSEs
48
+ export function parseRepoEnv(dockerfile) {
49
+ if (dockerfile === undefined)
50
+ return { ports: [] };
51
+ if (/^\s*FROM\s/im.test(dockerfile))
52
+ throw new BsError("bad_dockerfile", ".bottleship/Dockerfile has a FROM line", "drop it: the lines are appended to bottleship's base image, see \"Repo environment\" in the README");
53
+ const ports = [...dockerfile.matchAll(/^\s*EXPOSE\s+(.+)$/gim)].flatMap((m) => m[1].trim().split(/\s+/)).map((p) => Number(p.replace(/\/tcp$/, ""))).filter((p) => Number.isInteger(p) && p > 0);
54
+ return { dockerfile, ports };
55
+ }
56
+ // ponytail: tracked and untracked files minus .gitignore'd ones, without .git; send .git too if setup scripts turn out to need history
57
+ export async function workingTree(root) {
58
+ const listed = (await run("git", ["ls-files", "-z", "-co", "--exclude-standard"], { cwd: root })).stdout;
59
+ // ls-files still lists files deleted from the working tree but not from the index
60
+ return tarFiles(root, listed.split("\0").filter((f) => f && existsSync(join(root, f))));
61
+ }
62
+ // a tar of these files, relative to root
63
+ export function tarFiles(root, files) {
64
+ return new Promise((resolve, reject) => {
65
+ // COPYFILE_DISABLE keeps macOS tar from adding ._ files
66
+ const tar = spawn("tar", ["-c", "--no-xattrs", "--null", "-T", "-"], { cwd: root, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdio: ["pipe", "pipe", "pipe"] });
67
+ const chunks = [];
68
+ let stderr = "";
69
+ tar.stdout.on("data", (d) => chunks.push(d));
70
+ tar.stderr.on("data", (d) => (stderr += d));
71
+ tar.on("error", reject);
72
+ tar.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`tar exited ${code}: ${stderr.trim()}`))));
73
+ tar.stdin.end(files.join("\0"));
74
+ });
75
+ }
@@ -0,0 +1,66 @@
1
+ import { type GhRepo } from "./github.ts";
2
+ import type { CommentInput, Quote, ReviewComment, Verdict } from "./contract.ts";
3
+ export interface PrTarget {
4
+ repo: GhRepo;
5
+ number: number;
6
+ head?: string;
7
+ }
8
+ export type Asked = "started" | "running" | "failed";
9
+ export interface Request {
10
+ id: string;
11
+ where: string;
12
+ body: string;
13
+ quotes?: Quote[];
14
+ thread: {
15
+ by: string;
16
+ body: string;
17
+ }[];
18
+ }
19
+ export interface Waited {
20
+ comments: Request[];
21
+ done?: {
22
+ note?: string;
23
+ };
24
+ }
25
+ type Fail = {
26
+ ok: false;
27
+ code: number;
28
+ errors: string[];
29
+ };
30
+ type Result<T> = {
31
+ ok: true;
32
+ value: T;
33
+ } | Fail;
34
+ export interface StoreOptions {
35
+ file: string;
36
+ pr: () => PrTarget | undefined;
37
+ ask?: (message: string) => Promise<Asked>;
38
+ running?: () => Promise<boolean>;
39
+ watchMs?: number;
40
+ url: () => string;
41
+ changed: (comments: ReviewComment[]) => void;
42
+ listening: (on: boolean) => void;
43
+ }
44
+ export declare function commentStore(o: StoreOptions): {
45
+ list: () => ReviewComment[];
46
+ listening: () => boolean;
47
+ create(input: CommentInput, by?: {
48
+ agent: true;
49
+ }): Promise<Result<ReviewComment>>;
50
+ edit(id: string, body: string): Promise<Result<ReviewComment>>;
51
+ drop(id: string): Promise<Result<null>>;
52
+ retry(id: string): Result<ReviewComment>;
53
+ answer(id: string, a: {
54
+ body: string;
55
+ commit?: string;
56
+ }): Result<ReviewComment>;
57
+ finish(note?: string): void;
58
+ wait(ms: number): Promise<Waited>;
59
+ submit(v: Verdict): Promise<Result<{
60
+ url: string;
61
+ }>>;
62
+ sync(): Promise<void>;
63
+ };
64
+ export type CommentStore = ReturnType<typeof commentStore>;
65
+ export declare function boxMessage(requests: Request[], url: string, number: number): string;
66
+ export {};
@@ -0,0 +1,344 @@
1
+ // The comments in a review and where they go. To GitHub: each one lands in the reviewer's pending review (or posts
2
+ // at once as a single comment) as it is written, so the tour and github.com always agree. To an agent: requests it
3
+ // answers, handed to the box agent as they come in a PR review, or collected for the developer's own agent in a
4
+ // local one, which picks them up with `bottleship agent review next`. Persisted to comments.json.
5
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { addReviewReply, addReviewThread, commentStates, deleteGhComment, editGhComment, pendingReview, postSingle, postSingleReply, reviewThreads, startPendingReview, submitReview } from "./github.js";
7
+ const fail = (code, ...errors) => ({ ok: false, code, errors });
8
+ // enough for any real review, and a bound on what the box keeps
9
+ const MAX_COMMENTS = 1000;
10
+ // a wait that ended this recently still counts as an agent listening: it is about to wait again
11
+ const LISTEN_GRACE = 15_000;
12
+ const RETRY = 5_000;
13
+ // how often a PR review checks that the box agent still runs while it has comments
14
+ const WATCH = 4_000;
15
+ export function commentStore(o) {
16
+ let comments = [];
17
+ try {
18
+ const v = JSON.parse(readFileSync(o.file, "utf8"));
19
+ // drafts from before comments had a recipient were meant for GitHub; sync posts them to the pending review
20
+ // a single quote came before quotes
21
+ if (Array.isArray(v))
22
+ comments = v.map(({ quote, ...c }) => ({ by: "reviewer", to: "github", ...c, ...(quote && { quotes: [{ ...quote, kind: "selection" }] }) }));
23
+ }
24
+ catch { }
25
+ let next = comments.reduce((n, c) => Math.max(n, Number(c.id.replace(/^c/, "")) || 0), 0) + 1;
26
+ let done;
27
+ const waiters = [];
28
+ let lastWait = 0;
29
+ let heard = false;
30
+ let dispatching = false;
31
+ let retry;
32
+ let watching;
33
+ const save = () => {
34
+ writeFileSync(o.file + ".tmp", JSON.stringify(comments, null, 2));
35
+ renameSync(o.file + ".tmp", o.file);
36
+ o.changed(comments);
37
+ };
38
+ const byId = (id) => comments.find((c) => c.id === id);
39
+ const add = (c) => {
40
+ const full = { ...c, id: `c${next++}`, at: new Date().toISOString() };
41
+ comments.push(full);
42
+ return full;
43
+ };
44
+ const lineOf = (c) => ({ path: c.path, side: c.side, line: c.line, ...(c.startLine && { startLine: c.startLine, startSide: c.startSide }) });
45
+ const github = async (f) => {
46
+ try {
47
+ return { ok: true, value: await f() };
48
+ }
49
+ catch (e) {
50
+ return fail(502, e.message);
51
+ }
52
+ };
53
+ // the comment a thread here starts from
54
+ const rootOf = (c) => {
55
+ let r = c;
56
+ for (let i = 0; r.replyTo && i < MAX_COMMENTS; i++)
57
+ r = byId(r.replyTo) ?? r;
58
+ return r;
59
+ };
60
+ const where = (c) => {
61
+ const r = rootOf(c);
62
+ // a chat message says which page the reviewer was on when they wrote it
63
+ if (c.topic?.chat)
64
+ return c.topic.label;
65
+ if (r.topic)
66
+ return r.topic.label;
67
+ const lines = r.startLine && r.startLine !== r.line ? `lines ${r.startLine} to ${r.line}` : `line ${r.line}`;
68
+ return `${r.path}, ${lines} of the ${r.side === "LEFT" ? "old" : "new"} file${r.inReplyTo ? `, in the GitHub thread on comment ${r.inReplyTo} (src/data/review-comments.json)` : ""}`;
69
+ };
70
+ const request = (c) => {
71
+ const root = rootOf(c);
72
+ const thread = comments.filter((x) => x !== c && x.at <= c.at && rootOf(x) === root).map((x) => ({ by: x.by === "agent" ? "agent" : "reviewer", body: x.body }));
73
+ return { id: c.id, where: where(c), body: c.body, ...(c.quotes?.length && { quotes: c.quotes }), thread };
74
+ };
75
+ const isListening = () => waiters.length > 0 || Date.now() - lastWait < LISTEN_GRACE;
76
+ const tellListening = () => {
77
+ const on = isListening();
78
+ if (on !== heard)
79
+ o.listening((heard = on));
80
+ };
81
+ // the developer's agent picks up what was sent, and hears when the reviewer is done
82
+ function take() {
83
+ const sent = comments.filter((c) => c.to === "agent" && c.by === "reviewer" && c.status === "sent");
84
+ sent.forEach((c) => (c.status = "working"));
85
+ if (sent.length)
86
+ save();
87
+ const out = { comments: sent.map(request), ...(done && { done }) };
88
+ done = undefined;
89
+ return out;
90
+ }
91
+ function wake() {
92
+ if (!waiters.length)
93
+ return;
94
+ const w = waiters.shift();
95
+ clearTimeout(w.timer);
96
+ w.give(take());
97
+ tellListening();
98
+ }
99
+ // a box agent whose run ends without answering has stopped on those comments; the reviewer can send them again
100
+ const inFlight = () => comments.filter((c) => c.to === "agent" && c.by === "reviewer" && c.status === "working");
101
+ function watch() {
102
+ if (watching || !o.running || !o.pr())
103
+ return;
104
+ watching = setInterval(async () => {
105
+ if (!inFlight().length)
106
+ return void (clearInterval(watching), (watching = undefined));
107
+ if (await o.running())
108
+ return;
109
+ // an answer may have landed just before the run ended
110
+ const left = inFlight();
111
+ if (!left.length)
112
+ return;
113
+ left.forEach((c) => (c.status = "stopped"));
114
+ save();
115
+ }, o.watchMs ?? WATCH);
116
+ watching.unref();
117
+ }
118
+ // the box agent gets every comment sent to it so far in one follow-up, once it is free
119
+ async function dispatch() {
120
+ if (!o.ask || dispatching)
121
+ return;
122
+ const sent = comments.filter((c) => c.to === "agent" && c.by === "reviewer" && c.status === "sent");
123
+ if (!sent.length)
124
+ return;
125
+ dispatching = true;
126
+ try {
127
+ const r = await o.ask(boxMessage(sent.map(request), o.url(), o.pr()?.number ?? 0));
128
+ if (r === "started") {
129
+ sent.forEach((c) => (c.status = "working"));
130
+ save();
131
+ watch();
132
+ }
133
+ else {
134
+ clearTimeout(retry);
135
+ retry = setTimeout(() => void dispatch(), RETRY);
136
+ retry.unref();
137
+ }
138
+ }
139
+ finally {
140
+ dispatching = false;
141
+ }
142
+ }
143
+ // comments left working when the server last stopped are checked too
144
+ watch();
145
+ return {
146
+ list: () => comments,
147
+ listening: isListening,
148
+ // agent: the agent writes it because the reviewer asked; it is GitHub's and the reviewer's like any other
149
+ async create(input, by) {
150
+ if (comments.length >= MAX_COMMENTS)
151
+ return fail(409, `at most ${MAX_COMMENTS} comments`);
152
+ const pr = o.pr();
153
+ const { mode, ...c } = input;
154
+ if (c.replyTo && !byId(c.replyTo))
155
+ return fail(400, `replyTo: no comment ${c.replyTo}`);
156
+ if (c.to === "agent") {
157
+ const made = add({ ...c, by: "reviewer", status: "sent" });
158
+ save();
159
+ if (pr)
160
+ void dispatch();
161
+ else
162
+ wake();
163
+ return { ok: true, value: made };
164
+ }
165
+ if (!pr)
166
+ return fail(409, "these are local changes with no PR: comments go to your agent");
167
+ const at = lineOf(c);
168
+ const r = await github(async () => {
169
+ const p = await pendingReview(pr.repo, pr.number);
170
+ if (mode === "single") {
171
+ if (p.review)
172
+ throw new Error("you have a review in progress on GitHub: add the comment to it");
173
+ if (c.inReplyTo)
174
+ return { state: "posted", ...(await postSingleReply(pr.repo, pr.number, c.inReplyTo, c.body)) };
175
+ if (!pr.head)
176
+ throw new Error("the PR's head commit is unknown");
177
+ return { state: "posted", ...(await postSingle(pr.repo, pr.number, pr.head, at, c.body)) };
178
+ }
179
+ const review = p.review?.id ?? (await startPendingReview(pr.repo, p.pr, pr.head)).id;
180
+ if (c.inReplyTo) {
181
+ const thread = comments.find((x) => x.github?.id === c.inReplyTo)?.github?.thread ?? (await reviewThreads(pr.repo, pr.number)).find((t) => t.root === c.inReplyTo)?.id;
182
+ if (!thread)
183
+ throw new Error("the thread it replies to is gone");
184
+ return { state: "pending", ...(await addReviewReply(pr.repo, review, thread, c.body)) };
185
+ }
186
+ return { state: "pending", ...(await addReviewThread(pr.repo, review, at, c.body)) };
187
+ });
188
+ if (!r.ok)
189
+ return r;
190
+ const made = add({ ...c, by: "reviewer", ...(by?.agent && { agent: true }), github: r.value });
191
+ save();
192
+ return { ok: true, value: made };
193
+ },
194
+ async edit(id, body) {
195
+ const c = byId(id);
196
+ if (!c || c.by !== "reviewer")
197
+ return fail(404, `no comment ${id} of yours`);
198
+ if (c.to === "agent" && c.status !== "sent" && c.status !== "stopped")
199
+ return fail(409, "the agent is already working on it");
200
+ const pr = o.pr();
201
+ if (c.github && pr) {
202
+ const r = await github(() => editGhComment(pr.repo, c.github.node, body));
203
+ if (!r.ok)
204
+ return r;
205
+ }
206
+ c.body = body;
207
+ save();
208
+ return { ok: true, value: c };
209
+ },
210
+ async drop(id) {
211
+ const c = byId(id);
212
+ if (!c || c.by !== "reviewer")
213
+ return fail(404, `no comment ${id} of yours`);
214
+ if (c.to === "agent" && c.status !== "sent" && c.status !== "stopped")
215
+ return fail(409, "the agent is already working on it");
216
+ const pr = o.pr();
217
+ if (c.github && pr) {
218
+ const r = await github(() => deleteGhComment(pr.repo, c.github.node));
219
+ if (!r.ok)
220
+ return r;
221
+ }
222
+ comments = comments.filter((x) => x !== c);
223
+ save();
224
+ return { ok: true, value: null };
225
+ },
226
+ // sends a comment the agent stopped on again
227
+ retry(id) {
228
+ const c = byId(id);
229
+ if (!c || c.to !== "agent" || c.by !== "reviewer" || c.status !== "stopped")
230
+ return fail(409, `${id} is not a comment the agent stopped on`);
231
+ c.status = "sent";
232
+ save();
233
+ void dispatch();
234
+ return { ok: true, value: c };
235
+ },
236
+ // an agent's answer to a comment sent to it, with the commit it made
237
+ answer(id, a) {
238
+ const c = byId(id);
239
+ if (!c || c.to !== "agent" || c.by !== "reviewer")
240
+ return fail(404, `no comment ${id} sent to the agent`);
241
+ c.status = "done";
242
+ const made = add({ by: "agent", to: "agent", replyTo: id, body: a.body, ...(a.commit && { commit: a.commit }) });
243
+ save();
244
+ return { ok: true, value: made };
245
+ },
246
+ // local reviews: the reviewer is done, with a last word for the agent
247
+ finish(note) {
248
+ done = { ...(note?.trim() && { note: note.trim() }) };
249
+ wake();
250
+ },
251
+ // what was sent since the last call, or what arrives within ms
252
+ wait(ms) {
253
+ lastWait = Date.now();
254
+ if (done || comments.some((c) => c.to === "agent" && c.status === "sent")) {
255
+ tellListening();
256
+ return Promise.resolve(take());
257
+ }
258
+ return new Promise((give) => {
259
+ const w = {
260
+ give,
261
+ timer: setTimeout(() => {
262
+ waiters.splice(waiters.indexOf(w), 1);
263
+ lastWait = Date.now();
264
+ give({ comments: [] });
265
+ setTimeout(tellListening, LISTEN_GRACE + 100).unref();
266
+ }, ms),
267
+ };
268
+ waiters.push(w);
269
+ tellListening();
270
+ });
271
+ },
272
+ // submits the reviewer's pending review with the verdict; the comments in it are posted now
273
+ async submit(v) {
274
+ const pr = o.pr();
275
+ if (!pr)
276
+ return fail(409, "this review has no PR to submit to");
277
+ if (!v.event)
278
+ return fail(400, "choose a verdict before submitting");
279
+ const r = await github(async () => {
280
+ const p = await pendingReview(pr.repo, pr.number);
281
+ return submitReview(pr.repo, { pr: p.pr, review: p.review?.id, head: pr.head, event: v.event, body: v.body?.trim() || undefined });
282
+ });
283
+ if (!r.ok)
284
+ return r;
285
+ comments.forEach((c) => c.github?.state === "pending" && (c.github.state = "posted"));
286
+ save();
287
+ return { ok: true, value: { url: r.value } };
288
+ },
289
+ // brings the store in line with GitHub: pending comments changed, added, deleted or submitted on github.com
290
+ async sync() {
291
+ const pr = o.pr();
292
+ if (!pr)
293
+ return;
294
+ const before = JSON.stringify(comments);
295
+ const p = await pendingReview(pr.repo, pr.number);
296
+ const there = new Map((p.review?.comments ?? []).map((c) => [c.node, c]));
297
+ const gone = comments.filter((c) => c.github?.state === "pending" && !there.has(c.github.node));
298
+ const states = await commentStates(pr.repo, gone.map((c) => c.github.node));
299
+ comments = comments.filter((c) => !(c.github && gone.includes(c) && states[c.github.node] === null));
300
+ for (const c of gone)
301
+ if (states[c.github.node] === "SUBMITTED")
302
+ c.github.state = "posted";
303
+ const mine = new Set(comments.map((c) => c.github?.node));
304
+ for (const c of comments) {
305
+ const g = c.github && there.get(c.github.node);
306
+ if (g)
307
+ c.body = g.body;
308
+ }
309
+ for (const g of there.values()) {
310
+ if (mine.has(g.node) || !g.line)
311
+ continue;
312
+ add({ by: "reviewer", to: "github", body: g.body, path: g.path, side: g.side, line: g.line, ...(g.startLine && { startLine: g.startLine, startSide: g.startSide ?? g.side }), ...(g.replyTo && { inReplyTo: g.replyTo }), github: { state: "pending", node: g.node, id: g.id, ...(g.url && { url: g.url }) } });
313
+ }
314
+ // drafts from an older box that never reached GitHub go into the pending review now
315
+ for (const c of comments.filter((x) => x.to === "github" && !x.github)) {
316
+ const { id: _id, at: _at, by: _by, github: _g, ...rest } = c;
317
+ const made = await this.create({ ...rest, mode: "review" });
318
+ if (made.ok)
319
+ comments = comments.filter((x) => x !== c);
320
+ }
321
+ if (JSON.stringify(comments) !== before)
322
+ save();
323
+ },
324
+ };
325
+ }
326
+ // the follow-up the box agent gets in a PR review, with everything it needs to act on it
327
+ export function boxMessage(requests, url, number) {
328
+ const one = (r) => [
329
+ `${r.id}, from ${r.where}:`,
330
+ ...r.thread.map((t) => ` earlier, ${t.by}: ${t.body.replace(/\n/g, "\n ")}`),
331
+ ...(r.quotes ?? []).flatMap((q) => [` quoting ${q.kind === "comment" ? (q.where ?? "a comment") : `what they selected${q.where ? ` in ${q.where}` : ""}`}:`, ...q.text.split("\n").map((l) => ` > ${l}`)]),
332
+ ` ${r.body.replace(/\n/g, "\n ")}`,
333
+ ].join("\n");
334
+ return `The reviewer wrote to you in the tour's chat:
335
+
336
+ ${requests.map(one).join("\n\n")}
337
+
338
+ - Answer what they ask, checking in the box what you need to. When the answer is a change to lines in the diff, you may include it as a \`\`\`suggestion block that replaces exactly those lines.
339
+ - Change code only when they ask you to: change it in ~/workspace, check it in the running app, commit, and push to the PR's branch (gh pr view ${number} --json headRepository,headRepositoryOwner,headRefName says where; a fork takes it only if its author lets maintainers edit).
340
+ - Write your answers as ~/review/app/WRITING.md says: the answer first, then the evidence, short enough to read in under a minute.
341
+ - Answer every message, even when you change nothing: curl -s -X POST -H 'content-type: application/json' -d '{"body":"<what you did, or why not>","commit":"<the sha, if you committed>"}' ${url}/api/comments/<id>/answer
342
+ - If you pushed, refresh the tour without rebuilding it: gh pr diff ${number} > ~/review/app/src/data/pr.diff, update headRefOid and files in pr.json (gh pr view ${number} --json headRefOid,files), restart head if needed, fix the groups' line ranges and whatever in review.tsx is now wrong, rerun the steps the change affects, npm run check. Then note the update with one sentence on what changed: curl -s -X POST -H 'content-type: application/json' -d '{"note":"<what changed>"}' ${url}/api/updates
343
+ - Never comment on GitHub on your own. Only when the reviewer explicitly asks you to leave a comment, add it to their pending review, where only they see it until they submit: curl -s -X POST -H 'content-type: application/json' -d '{"path":"<file>","side":"RIGHT","line":<last line>,"startSide":"RIGHT","startLine":<first line, for a range>,"body":"<the comment>"}' ${url}/api/comments/agent ("side":"LEFT" for lines of the old file; "inReplyTo":<id of a thread's first comment> to reply in a GitHub thread). Add "mode":"single" only if they asked for it to be posted right away. Never review or approve: the reviewer submits their own review.`;
344
+ }
@@ -0,0 +1,82 @@
1
+ export type Status = "ok" | "fail" | "neutral";
2
+ export interface ReviewEvent {
3
+ instance: string;
4
+ kind: "request" | "hook" | "ok" | "fail";
5
+ text: string;
6
+ at?: string;
7
+ step?: string;
8
+ flow?: {
9
+ id: string;
10
+ edge?: [string, string];
11
+ node?: string;
12
+ status?: Status;
13
+ label?: string;
14
+ };
15
+ overlay?: {
16
+ title: string;
17
+ body?: string;
18
+ status: Status;
19
+ };
20
+ }
21
+ type Errors = string[];
22
+ export declare function validateEvent(v: unknown): Errors;
23
+ export type Side = "LEFT" | "RIGHT";
24
+ export interface ReviewComment {
25
+ id: string;
26
+ at: string;
27
+ by: "reviewer" | "agent";
28
+ to: "github" | "agent";
29
+ body: string;
30
+ path?: string;
31
+ side?: Side;
32
+ line?: number;
33
+ startSide?: Side;
34
+ startLine?: number;
35
+ topic?: {
36
+ chapter?: string;
37
+ chat?: boolean;
38
+ label: string;
39
+ };
40
+ quotes?: Quote[];
41
+ inReplyTo?: number;
42
+ replyTo?: string;
43
+ agent?: true;
44
+ github?: {
45
+ state: "pending" | "posted";
46
+ node: string;
47
+ id: number;
48
+ url?: string;
49
+ thread?: string;
50
+ };
51
+ status?: "sent" | "working" | "done" | "stopped";
52
+ commit?: string;
53
+ }
54
+ export interface Quote {
55
+ text: string;
56
+ where?: string;
57
+ kind?: "selection" | "comment";
58
+ }
59
+ export type CommentInput = Pick<ReviewComment, "to" | "body" | "path" | "side" | "line" | "startSide" | "startLine" | "topic" | "quotes" | "inReplyTo" | "replyTo"> & {
60
+ mode?: "single" | "review";
61
+ };
62
+ export declare const MAX_COMMENT = 65536;
63
+ export declare function validateComment(v: unknown): Errors;
64
+ export declare function validateAnswer(v: unknown): Errors;
65
+ export declare const EVENTS: readonly ["COMMENT", "APPROVE", "REQUEST_CHANGES"];
66
+ export interface Verdict {
67
+ event?: (typeof EVENTS)[number];
68
+ body?: string;
69
+ }
70
+ export declare function validateVerdict(v: unknown): Errors;
71
+ export declare const STAGES: {
72
+ readonly read: "Reading the change";
73
+ readonly instances: "Starting base and head";
74
+ readonly hooks: "Adding review hooks";
75
+ readonly steps: "Writing the steps";
76
+ readonly shots: "Taking screenshots";
77
+ readonly tour: "Writing the tour";
78
+ readonly check: "Checking every page";
79
+ };
80
+ export type Stage = keyof typeof STAGES;
81
+ export declare function validateProgress(v: unknown): Errors;
82
+ export {};