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.
- package/CHANGELOG.md +17 -0
- package/README.md +118 -113
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -83
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +3 -4
- package/dist/src/init.js +120 -205
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/secrets.d.ts +1 -0
- package/dist/src/secrets.js +11 -1
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// The review server that runs in the box next to the app: serves the review app the agent builds in <dir>/app,
|
|
2
|
+
// files under <dir>, takes events from review hooks and streams them to the app, and runs step scripts on request.
|
|
3
|
+
//
|
|
4
|
+
// GET /, /assets/... the built review app (<dir>/app/dist), or a page saying it is being set up
|
|
5
|
+
// GET /api/status { app, progress }: whether the review app is built, and the stages the agent reported so far
|
|
6
|
+
// POST /api/progress { stage }: the agent is at this stage (see STAGES in contract.ts)
|
|
7
|
+
// GET /api/urls { "<box port>": "<URL on the reviewer's machine>" }
|
|
8
|
+
// GET /api/events server-sent events: state, every event so far, ready, then live event, step and reset
|
|
9
|
+
// POST /api/events an event from a hook or step script; 400 { errors } when it is malformed
|
|
10
|
+
// POST /api/steps/:id/run runs <dir>/steps/<id>.sh; 404 without one, 409 while another step runs
|
|
11
|
+
// POST /api/reset forgets events and step results, e.g. before running everything again
|
|
12
|
+
// GET /files/<path> files under <dir>, e.g. screenshots in shots/
|
|
13
|
+
// GET /api/comments { comments }: every comment in the review (see comments.ts), kept in <dir>/comments.json
|
|
14
|
+
// POST /api/comments a comment for GitHub (into the pending review, or single) or a chat message for the agent; 201 with it,
|
|
15
|
+
// 400 { errors } when it is malformed, 502 when GitHub refuses it
|
|
16
|
+
// PATCH /api/comments/:id { body }: rewrites one of the reviewer's comments, on GitHub too
|
|
17
|
+
// DELETE /api/comments/:id deletes one, on GitHub too
|
|
18
|
+
// POST /api/comments/:id/answer { body, commit? }: the agent's answer to a comment sent to it
|
|
19
|
+
// POST /api/comments/:id/retry sends a comment the box agent stopped on again
|
|
20
|
+
// POST /api/comments/agent { path, side, line, ..., body, mode? }: a comment the agent writes because the reviewer asked;
|
|
21
|
+
// it goes into the reviewer's pending review unless mode is single
|
|
22
|
+
// POST /api/finish { note? }: local reviews: the reviewer is done
|
|
23
|
+
// POST /api/wait { ms? }: local reviews: the chat messages for the developer's agent, or { comments: [] } after ms; done once finished
|
|
24
|
+
// GET /api/verdict { event?, body? }: how the reviewer means to finish, kept in <dir>/verdict.json
|
|
25
|
+
// POST /api/verdict replaces it
|
|
26
|
+
// POST /api/submit submits the reviewer's pending review on GitHub with the verdict; { url }
|
|
27
|
+
// GET /api/pr the PR as it stands on GitHub: state, checks and action runs, reviews, whether it can merge
|
|
28
|
+
// POST /api/pr/merge { method }: merges it, only at the head the tour shows; 409 when someone pushed since
|
|
29
|
+
// POST /api/updates { note }: the change was updated and the tour refreshed for it, or is being; { n }
|
|
30
|
+
// POST /api/updates/:n/refreshed the tour is refreshed for update n
|
|
31
|
+
// Every POST, PATCH and DELETE must be application/json. Changes stream as comments, verdict, agent and updates events.
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { createServer } from "node:http";
|
|
35
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
36
|
+
import { mergePr, parsePrUrl, prStatus } from "./github.js";
|
|
37
|
+
import { settingUpPage } from "./setting-up.js";
|
|
38
|
+
import { commentStore } from "./comments.js";
|
|
39
|
+
import { MAX_COMMENT, STAGES, validateAnswer, validateComment, validateEvent, validateProgress, validateVerdict } from "./contract.js";
|
|
40
|
+
const TYPES = {
|
|
41
|
+
".html": "text/html; charset=utf-8",
|
|
42
|
+
".js": "text/javascript; charset=utf-8",
|
|
43
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
44
|
+
".css": "text/css; charset=utf-8",
|
|
45
|
+
".json": "application/json; charset=utf-8",
|
|
46
|
+
".svg": "image/svg+xml",
|
|
47
|
+
".png": "image/png",
|
|
48
|
+
".jpg": "image/jpeg",
|
|
49
|
+
".jpeg": "image/jpeg",
|
|
50
|
+
".gif": "image/gif",
|
|
51
|
+
".webp": "image/webp",
|
|
52
|
+
".mp4": "video/mp4",
|
|
53
|
+
".webm": "video/webm",
|
|
54
|
+
".woff2": "font/woff2",
|
|
55
|
+
".txt": "text/plain; charset=utf-8",
|
|
56
|
+
};
|
|
57
|
+
const MAX_BODY = 4 * MAX_COMMENT;
|
|
58
|
+
// how long a wait for comments may hold its request
|
|
59
|
+
const MAX_WAIT = 60_000;
|
|
60
|
+
// GitHub is asked again for the reviewer's pending review at most this often, when a page connects
|
|
61
|
+
const SYNC_EVERY = 15_000;
|
|
62
|
+
// and for the PR's status at most this often, while its page polls
|
|
63
|
+
const STATUS_EVERY = 10_000;
|
|
64
|
+
const METHODS = ["MERGE", "SQUASH", "REBASE"];
|
|
65
|
+
const STEP_ID = /^[A-Za-z0-9][\w.-]*$/;
|
|
66
|
+
// a failed step's output, enough for the reviewer to see why
|
|
67
|
+
const tail = (s) => (s.length > 4000 ? "…" + s.slice(-4000) : s);
|
|
68
|
+
function readBody(req) {
|
|
69
|
+
return new Promise((res, rej) => {
|
|
70
|
+
let body = "";
|
|
71
|
+
req.on("data", (c) => {
|
|
72
|
+
body += c;
|
|
73
|
+
if (body.length > MAX_BODY)
|
|
74
|
+
req.destroy(new Error("body too large"));
|
|
75
|
+
});
|
|
76
|
+
req.on("end", () => res(body));
|
|
77
|
+
req.on("error", rej);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const json = (res, code, body) => res.writeHead(code, { "content-type": TYPES[".json"] }).end(JSON.stringify(body));
|
|
81
|
+
// the whole file, or the byte range asked for, which video players need to play and loop a clip
|
|
82
|
+
function sendFile(res, file, range) {
|
|
83
|
+
let body;
|
|
84
|
+
try {
|
|
85
|
+
if (!statSync(file).isFile())
|
|
86
|
+
throw new Error("not a file");
|
|
87
|
+
body = readFileSync(file);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return res.writeHead(404).end();
|
|
91
|
+
}
|
|
92
|
+
const head = { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store", "accept-ranges": "bytes" };
|
|
93
|
+
const m = range?.match(/^bytes=(\d*)-(\d*)$/);
|
|
94
|
+
if (!m || (!m[1] && !m[2]))
|
|
95
|
+
return res.writeHead(200, head).end(body);
|
|
96
|
+
// bytes=a-b, bytes=a- (to the end), bytes=-n (the last n)
|
|
97
|
+
const start = m[1] ? Number(m[1]) : Math.max(0, body.length - Number(m[2]));
|
|
98
|
+
const end = m[1] && m[2] ? Math.min(Number(m[2]), body.length - 1) : body.length - 1;
|
|
99
|
+
if (start >= body.length || start > end)
|
|
100
|
+
return res.writeHead(416, { "content-range": `bytes */${body.length}` }).end();
|
|
101
|
+
res.writeHead(206, { ...head, "content-range": `bytes ${start}-${end}/${body.length}` }).end(body.subarray(start, end + 1));
|
|
102
|
+
}
|
|
103
|
+
// a request path inside root, decoded and resolved before the check so neither ../ nor %2e%2e%2f leaves it
|
|
104
|
+
function inside(root, rel) {
|
|
105
|
+
let decoded;
|
|
106
|
+
try {
|
|
107
|
+
decoded = decodeURIComponent(rel);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const file = resolve(root, "." + sep + decoded);
|
|
113
|
+
return file.startsWith(root + sep) ? file : undefined;
|
|
114
|
+
}
|
|
115
|
+
export async function startReviewServer({ dir: rawDir, port = 7777, host = "127.0.0.1", urls, ask, state, watchMs }) {
|
|
116
|
+
const dir = resolve(rawDir);
|
|
117
|
+
const dist = join(dir, "app", "dist");
|
|
118
|
+
let events = [];
|
|
119
|
+
let steps = {};
|
|
120
|
+
let running;
|
|
121
|
+
let submitting = false;
|
|
122
|
+
const progress = [];
|
|
123
|
+
// the PR the page is about, from the data the review app gets; nothing when it cannot be read
|
|
124
|
+
const subject = () => {
|
|
125
|
+
try {
|
|
126
|
+
const pr = JSON.parse(readFileSync(join(dir, "app", "src", "data", "pr.json"), "utf8"));
|
|
127
|
+
const repo = String(pr.repo ?? String(pr.url).replace(/^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/.*$/, "$1")).split("/").pop();
|
|
128
|
+
return { ref: pr.number ? `${repo} #${pr.number}` : `${repo}, local changes`, title: String(pr.title ?? "") };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const clients = new Set();
|
|
135
|
+
let url = "";
|
|
136
|
+
const verdictFile = join(dir, "verdict.json");
|
|
137
|
+
const updatesFile = join(dir, "updates.json");
|
|
138
|
+
const load = (file, ok, empty) => {
|
|
139
|
+
try {
|
|
140
|
+
const v = JSON.parse(readFileSync(file, "utf8"));
|
|
141
|
+
return ok(v) ? v : empty;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return empty;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
let verdict = load(verdictFile, (v) => validateVerdict(v).length === 0, {});
|
|
148
|
+
const updates = load(updatesFile, Array.isArray, []);
|
|
149
|
+
// written whole and renamed into place, so a crash never leaves half a file
|
|
150
|
+
const save = (file, v) => {
|
|
151
|
+
writeFileSync(file + ".tmp", JSON.stringify(v, null, 2));
|
|
152
|
+
renameSync(file + ".tmp", file);
|
|
153
|
+
};
|
|
154
|
+
// the PR the review is of, from the data the review app gets; none for local changes
|
|
155
|
+
const prTarget = () => {
|
|
156
|
+
try {
|
|
157
|
+
const pr = JSON.parse(readFileSync(join(dir, "app", "src", "data", "pr.json"), "utf8"));
|
|
158
|
+
const at = parsePrUrl(String(pr.url));
|
|
159
|
+
return at && { ...at, head: typeof pr.headRefOid === "string" ? pr.headRefOid : undefined };
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const askAgent = (message) => new Promise((ok) => {
|
|
166
|
+
if (!ask)
|
|
167
|
+
return ok("failed");
|
|
168
|
+
const child = spawn("bash", [ask], { stdio: ["pipe", "pipe", "ignore"] });
|
|
169
|
+
let o = "";
|
|
170
|
+
child.stdout.on("data", (d) => (o += d));
|
|
171
|
+
child.on("close", () => ok(o.trim() === "started" ? "started" : o.trim() === "running" ? "running" : "failed"));
|
|
172
|
+
child.on("error", () => ok("failed"));
|
|
173
|
+
child.stdin.end(message);
|
|
174
|
+
});
|
|
175
|
+
const agentRunning = () => new Promise((ok) => {
|
|
176
|
+
if (!state)
|
|
177
|
+
return ok(true);
|
|
178
|
+
const child = spawn("bash", [state], { stdio: ["ignore", "pipe", "ignore"] });
|
|
179
|
+
let o = "";
|
|
180
|
+
child.stdout.on("data", (d) => (o += d));
|
|
181
|
+
child.on("close", () => ok(o.trim() !== "idle"));
|
|
182
|
+
child.on("error", () => ok(true));
|
|
183
|
+
});
|
|
184
|
+
const agentState = () => ({ local: !prTarget(), listening: store.listening() });
|
|
185
|
+
const store = commentStore({
|
|
186
|
+
file: join(dir, "comments.json"),
|
|
187
|
+
pr: prTarget,
|
|
188
|
+
ask: askAgent,
|
|
189
|
+
running: agentRunning,
|
|
190
|
+
watchMs,
|
|
191
|
+
url: () => url,
|
|
192
|
+
changed: (comments) => broadcast("comments", { comments }),
|
|
193
|
+
listening: () => broadcast("agent", agentState()),
|
|
194
|
+
});
|
|
195
|
+
let status;
|
|
196
|
+
let merging = false;
|
|
197
|
+
const freshStatus = async (pr) => {
|
|
198
|
+
status = { at: Date.now(), value: await prStatus(pr.repo, pr.number) };
|
|
199
|
+
return status.value;
|
|
200
|
+
};
|
|
201
|
+
let synced = 0;
|
|
202
|
+
const sync = () => {
|
|
203
|
+
if (Date.now() - synced < SYNC_EVERY)
|
|
204
|
+
return;
|
|
205
|
+
synced = Date.now();
|
|
206
|
+
store.sync().catch(() => { });
|
|
207
|
+
};
|
|
208
|
+
sync();
|
|
209
|
+
const send = (res, event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
210
|
+
const broadcast = (event, data) => clients.forEach((c) => send(c, event, data));
|
|
211
|
+
const setStep = (s) => {
|
|
212
|
+
steps[s.id] = s;
|
|
213
|
+
broadcast("step", s);
|
|
214
|
+
};
|
|
215
|
+
function runStep(id, script) {
|
|
216
|
+
running = id;
|
|
217
|
+
setStep({ id, status: "running" });
|
|
218
|
+
let output = "";
|
|
219
|
+
const child = spawn("bash", [script], { cwd: dir, env: { ...process.env, BOTTLESHIP_REVIEW_URL: url, BOTTLESHIP_REVIEW_STEP: id }, stdio: ["ignore", "pipe", "pipe"] });
|
|
220
|
+
child.stdout.on("data", (d) => (output += d));
|
|
221
|
+
child.stderr.on("data", (d) => (output += d));
|
|
222
|
+
const finish = (ok) => {
|
|
223
|
+
if (running !== id)
|
|
224
|
+
return;
|
|
225
|
+
running = undefined;
|
|
226
|
+
setStep(ok ? { id, status: "done" } : { id, status: "failed", output: tail(output) });
|
|
227
|
+
};
|
|
228
|
+
child.on("error", (e) => ((output += e.message), finish(false)));
|
|
229
|
+
child.on("close", (code) => finish(code === 0));
|
|
230
|
+
}
|
|
231
|
+
async function handle(req, res) {
|
|
232
|
+
const u = new URL(req.url ?? "/", "http://review");
|
|
233
|
+
const path = u.pathname;
|
|
234
|
+
// a JSON content type makes a browser preflight cross-origin requests, which this server never allows, so other pages cannot run steps
|
|
235
|
+
if (req.method !== "GET" && req.method !== "HEAD" && !(req.headers["content-type"] ?? "").startsWith("application/json"))
|
|
236
|
+
return json(res, 415, { errors: ["send content-type: application/json"] });
|
|
237
|
+
if (req.method === "GET" && path === "/api/status")
|
|
238
|
+
return json(res, 200, { app: existsSync(join(dist, "index.html")), progress: progress.map((s) => STAGES[s]) });
|
|
239
|
+
if (req.method === "POST" && path === "/api/progress") {
|
|
240
|
+
let body;
|
|
241
|
+
try {
|
|
242
|
+
body = JSON.parse(await readBody(req));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return json(res, 400, { errors: ["progress: expected JSON"] });
|
|
246
|
+
}
|
|
247
|
+
const problems = validateProgress(body);
|
|
248
|
+
if (problems.length)
|
|
249
|
+
return json(res, 400, { errors: problems });
|
|
250
|
+
const stage = body.stage;
|
|
251
|
+
if (progress.at(-1) !== stage)
|
|
252
|
+
progress.push(stage);
|
|
253
|
+
return res.writeHead(204).end();
|
|
254
|
+
}
|
|
255
|
+
if (req.method === "GET" && path === "/api/urls") {
|
|
256
|
+
try {
|
|
257
|
+
return json(res, 200, urls ? JSON.parse(readFileSync(urls, "utf8")) : {});
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return json(res, 200, {});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (req.method === "GET" && path === "/api/events") {
|
|
264
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
265
|
+
send(res, "state", { steps });
|
|
266
|
+
send(res, "comments", { comments: store.list() });
|
|
267
|
+
send(res, "verdict", verdict);
|
|
268
|
+
send(res, "agent", agentState());
|
|
269
|
+
send(res, "updates", { updates });
|
|
270
|
+
events.forEach((e) => send(res, "event", e));
|
|
271
|
+
send(res, "ready", {});
|
|
272
|
+
clients.add(res);
|
|
273
|
+
req.on("close", () => clients.delete(res));
|
|
274
|
+
sync();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (req.method === "POST" && path === "/api/events") {
|
|
278
|
+
let body;
|
|
279
|
+
try {
|
|
280
|
+
body = JSON.parse(await readBody(req));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return json(res, 400, { errors: ["event: expected JSON"] });
|
|
284
|
+
}
|
|
285
|
+
const problems = validateEvent(body);
|
|
286
|
+
if (problems.length)
|
|
287
|
+
return json(res, 400, { errors: problems });
|
|
288
|
+
const ev = { ...body, ...(running && { step: running }), seq: events.length + 1 };
|
|
289
|
+
events.push(ev);
|
|
290
|
+
broadcast("event", ev);
|
|
291
|
+
return json(res, 201, { seq: ev.seq });
|
|
292
|
+
}
|
|
293
|
+
const run = req.method === "POST" && path.match(/^\/api\/steps\/([^/]+)\/run$/);
|
|
294
|
+
if (run) {
|
|
295
|
+
const id = decodeURIComponent(run[1]);
|
|
296
|
+
const script = join(dir, "steps", `${id}.sh`);
|
|
297
|
+
if (!STEP_ID.test(id) || !existsSync(script))
|
|
298
|
+
return json(res, 404, { errors: [`no step "${id}": write ~/review/steps/${id}.sh`] });
|
|
299
|
+
if (running)
|
|
300
|
+
return json(res, 409, { errors: [`step "${running}" is still running`] });
|
|
301
|
+
runStep(id, script);
|
|
302
|
+
return json(res, 202, { id, status: "running" });
|
|
303
|
+
}
|
|
304
|
+
if (req.method === "POST" && path === "/api/reset") {
|
|
305
|
+
if (running)
|
|
306
|
+
return json(res, 409, { errors: [`step "${running}" is still running`] });
|
|
307
|
+
events = [];
|
|
308
|
+
steps = {};
|
|
309
|
+
broadcast("reset", {});
|
|
310
|
+
return res.writeHead(204).end();
|
|
311
|
+
}
|
|
312
|
+
if (path === "/api/comments" && req.method === "GET")
|
|
313
|
+
return json(res, 200, { comments: store.list() });
|
|
314
|
+
if (path === "/api/pr" && req.method === "GET") {
|
|
315
|
+
const pr = prTarget();
|
|
316
|
+
if (!pr)
|
|
317
|
+
return json(res, 409, { errors: ["these are local changes with no PR"] });
|
|
318
|
+
try {
|
|
319
|
+
return json(res, 200, status && Date.now() - status.at < STATUS_EVERY ? status.value : await freshStatus(pr));
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
return json(res, 502, { errors: [e.message] });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (path === "/api/verdict" && req.method === "GET")
|
|
326
|
+
return json(res, 200, verdict);
|
|
327
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
328
|
+
// everything below takes a JSON body
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
let body;
|
|
332
|
+
try {
|
|
333
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return json(res, 400, { errors: ["expected a JSON body"] });
|
|
337
|
+
}
|
|
338
|
+
const reply = (r, code = 200) => (r.ok ? (r.value === null ? res.writeHead(204).end() : json(res, code, r.value)) : json(res, r.code, { errors: r.errors }));
|
|
339
|
+
if (path === "/api/verdict" && req.method === "POST") {
|
|
340
|
+
const problems = validateVerdict(body);
|
|
341
|
+
if (problems.length)
|
|
342
|
+
return json(res, 400, { errors: problems });
|
|
343
|
+
verdict = body;
|
|
344
|
+
save(verdictFile, verdict);
|
|
345
|
+
broadcast("verdict", verdict);
|
|
346
|
+
return json(res, 200, verdict);
|
|
347
|
+
}
|
|
348
|
+
if (path === "/api/comments/agent" && req.method === "POST") {
|
|
349
|
+
const input = { to: "github", mode: "review", ...(body ?? {}) };
|
|
350
|
+
const problems = validateComment(input);
|
|
351
|
+
if (problems.length)
|
|
352
|
+
return json(res, 400, { errors: problems });
|
|
353
|
+
return reply(await store.create(input, { agent: true }), 201);
|
|
354
|
+
}
|
|
355
|
+
if (path === "/api/comments" && req.method === "POST") {
|
|
356
|
+
const problems = validateComment(body);
|
|
357
|
+
if (problems.length)
|
|
358
|
+
return json(res, 400, { errors: problems });
|
|
359
|
+
return reply(await store.create(body), 201);
|
|
360
|
+
}
|
|
361
|
+
const one = path.match(/^\/api\/comments\/(c\d+)(\/answer)?$/);
|
|
362
|
+
if (one && one[2] && req.method === "POST") {
|
|
363
|
+
const problems = validateAnswer(body);
|
|
364
|
+
if (problems.length)
|
|
365
|
+
return json(res, 400, { errors: problems });
|
|
366
|
+
return reply(store.answer(one[1], body), 201);
|
|
367
|
+
}
|
|
368
|
+
const retry = path.match(/^\/api\/comments\/(c\d+)\/retry$/);
|
|
369
|
+
if (retry && req.method === "POST")
|
|
370
|
+
return reply(store.retry(retry[1]));
|
|
371
|
+
if (one && !one[2] && req.method === "DELETE")
|
|
372
|
+
return reply(await store.drop(one[1]));
|
|
373
|
+
if (one && !one[2] && req.method === "PATCH") {
|
|
374
|
+
const problems = [...(typeof body?.body === "string" && body.body.trim() && body.body.length <= MAX_COMMENT ? [] : ["body: expected a non-empty string"]), ...Object.keys(body ?? {}).filter((k) => k !== "body").map((k) => `${k}: only body can change`)];
|
|
375
|
+
if (problems.length)
|
|
376
|
+
return json(res, 400, { errors: problems });
|
|
377
|
+
return reply(await store.edit(one[1], body.body));
|
|
378
|
+
}
|
|
379
|
+
if (path === "/api/finish" && req.method === "POST") {
|
|
380
|
+
if (body?.note !== undefined && (typeof body.note !== "string" || body.note.length > MAX_COMMENT))
|
|
381
|
+
return json(res, 400, { errors: ["note: expected a string"] });
|
|
382
|
+
store.finish(body?.note);
|
|
383
|
+
return res.writeHead(204).end();
|
|
384
|
+
}
|
|
385
|
+
if (path === "/api/wait" && req.method === "POST") {
|
|
386
|
+
const ms = Math.min(MAX_WAIT, Math.max(0, Number(body?.ms ?? 25_000) || 0));
|
|
387
|
+
return json(res, 200, await store.wait(ms));
|
|
388
|
+
}
|
|
389
|
+
if (path === "/api/submit" && req.method === "POST") {
|
|
390
|
+
if (submitting)
|
|
391
|
+
return json(res, 409, { errors: ["already submitting"] });
|
|
392
|
+
submitting = true;
|
|
393
|
+
try {
|
|
394
|
+
const r = await store.submit(verdict);
|
|
395
|
+
if (r.ok) {
|
|
396
|
+
verdict = {};
|
|
397
|
+
save(verdictFile, verdict);
|
|
398
|
+
broadcast("verdict", verdict);
|
|
399
|
+
}
|
|
400
|
+
return reply(r);
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
submitting = false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (path === "/api/pr/merge" && req.method === "POST") {
|
|
407
|
+
const pr = prTarget();
|
|
408
|
+
if (!pr)
|
|
409
|
+
return json(res, 409, { errors: ["these are local changes with no PR"] });
|
|
410
|
+
if (!METHODS.includes(body?.method))
|
|
411
|
+
return json(res, 400, { errors: [`method: expected one of ${METHODS.join(", ")}`] });
|
|
412
|
+
if (merging)
|
|
413
|
+
return json(res, 409, { errors: ["already merging"] });
|
|
414
|
+
merging = true;
|
|
415
|
+
try {
|
|
416
|
+
const now = await freshStatus(pr);
|
|
417
|
+
if (!now.canMerge)
|
|
418
|
+
return json(res, 403, { errors: ["your GitHub account cannot merge this PR"] });
|
|
419
|
+
if (!now.methods.includes(body.method))
|
|
420
|
+
return json(res, 400, { errors: [`the repo does not allow ${body.method.toLowerCase()} merges`] });
|
|
421
|
+
// the commit the reviewer saw, so nothing pushed since goes in unseen
|
|
422
|
+
if (pr.head && now.head.oid !== pr.head)
|
|
423
|
+
return json(res, 409, { errors: ["someone pushed to the PR since this tour was built: review the new commits before merging"] });
|
|
424
|
+
await mergePr(pr.repo, { id: now.id, method: body.method, head: now.head.oid });
|
|
425
|
+
return json(res, 200, await freshStatus(pr));
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
return json(res, 502, { errors: [e.message] });
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
merging = false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (path === "/api/updates" && req.method === "POST") {
|
|
435
|
+
if (typeof body?.note !== "string" || !body.note.trim() || body.note.length > 2000)
|
|
436
|
+
return json(res, 400, { errors: ["note: expected a sentence on what changed"] });
|
|
437
|
+
const u = { n: updates.length + 1, at: new Date().toISOString(), note: body.note.trim(), ...(body.refreshed !== false && { refreshed: new Date().toISOString() }) };
|
|
438
|
+
updates.push(u);
|
|
439
|
+
save(updatesFile, updates);
|
|
440
|
+
broadcast("updates", { updates });
|
|
441
|
+
return json(res, 201, u);
|
|
442
|
+
}
|
|
443
|
+
const refreshed = path.match(/^\/api\/updates\/(\d+)\/refreshed$/);
|
|
444
|
+
if (refreshed && req.method === "POST") {
|
|
445
|
+
const u = updates.find((x) => x.n === Number(refreshed[1]));
|
|
446
|
+
if (!u)
|
|
447
|
+
return json(res, 404, { errors: [`no update ${refreshed[1]}`] });
|
|
448
|
+
u.refreshed = new Date().toISOString();
|
|
449
|
+
save(updatesFile, updates);
|
|
450
|
+
broadcast("updates", { updates });
|
|
451
|
+
return json(res, 200, u);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (req.method === "GET" && path.startsWith("/files/")) {
|
|
455
|
+
const file = inside(dir, path.slice("/files/".length));
|
|
456
|
+
return file ? sendFile(res, file, req.headers.range) : res.writeHead(404).end();
|
|
457
|
+
}
|
|
458
|
+
if (req.method === "GET") {
|
|
459
|
+
if (!existsSync(join(dist, "index.html")))
|
|
460
|
+
return path === "/" ? res.writeHead(200, { "content-type": TYPES[".html"], "cache-control": "no-store" }).end(settingUpPage(subject())) : res.writeHead(404).end();
|
|
461
|
+
const file = path === "/" ? join(dist, "index.html") : inside(dist, path.slice(1));
|
|
462
|
+
return file ? sendFile(res, file) : res.writeHead(404).end();
|
|
463
|
+
}
|
|
464
|
+
res.writeHead(404).end();
|
|
465
|
+
}
|
|
466
|
+
const server = createServer((req, res) => {
|
|
467
|
+
handle(req, res).catch((e) => {
|
|
468
|
+
if (!res.headersSent)
|
|
469
|
+
json(res, 500, { errors: [e.message] });
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
await new Promise((ok, fail) => server.once("error", fail).listen(port, host, () => ok()));
|
|
473
|
+
const addr = server.address();
|
|
474
|
+
// a server on every interface is still reached locally at 127.0.0.1, which is what step scripts get
|
|
475
|
+
url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${typeof addr === "object" && addr ? addr.port : port}`;
|
|
476
|
+
return {
|
|
477
|
+
url,
|
|
478
|
+
close: () => new Promise((ok) => {
|
|
479
|
+
clients.forEach((c) => c.end());
|
|
480
|
+
server.closeAllConnections();
|
|
481
|
+
server.close(() => ok());
|
|
482
|
+
}),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The page the review link shows until the agent has built the tour: the PR in the corner where the tour's rail will
|
|
2
|
+
// put it, the agent's boat riding the waves, and the stages the agent has reported, sliding up as they come.
|
|
3
|
+
// It opens the tour as soon as /api/status says it is built.
|
|
4
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
5
|
+
// the review kit's tokens, so the page and the tour feel like one thing
|
|
6
|
+
const STYLE = `
|
|
7
|
+
:root { --bg: #f4f4f5; --hover: rgba(0,0,0,.035); --text: #1b1b1f; --text-2: #67676f; --text-3: #a1a1a9; --ease: cubic-bezier(.23,1,.32,1); color-scheme: light; }
|
|
8
|
+
:root[data-theme="dark"] { --bg: #0c0c0d; --hover: rgba(255,255,255,.035); --text: #e8e8eb; --text-2: #96969d; --text-3: #5f5f66; color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
html, body { margin: 0; height: 100%; }
|
|
11
|
+
body { background: var(--bg); color: var(--text); font: 400 14px/1.5 "Inter", -apple-system, BlinkMacSystemFont, sans-serif; letter-spacing: -0.006em; -webkit-font-smoothing: antialiased; display: grid; place-items: center; }
|
|
12
|
+
.corner { position: fixed; top: 20px; left: 20px; max-width: 260px; font-size: 13px; }
|
|
13
|
+
.corner .r { color: var(--text-3); display: flex; align-items: center; gap: 6px; }
|
|
14
|
+
.corner b { display: block; margin-top: 6px; font-weight: 500; line-height: 1.35; }
|
|
15
|
+
svg.i { width: 14px; height: 14px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
16
|
+
.icon-btn { all: unset; position: fixed; top: 16px; right: 16px; width: 28px; height: 28px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; }
|
|
17
|
+
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
|
18
|
+
.icon-btn:active { transform: scale(.94); }
|
|
19
|
+
.icon-btn:focus-visible { outline: 2px solid var(--text-3); outline-offset: 2px; }
|
|
20
|
+
main { display: flex; flex-direction: column; align-items: center; gap: 24px; animation: rise 260ms var(--ease); }
|
|
21
|
+
/* the boat the agent's chat shows while it works, in the page's stronger tones: a sail riding two waves */
|
|
22
|
+
.sailing { display: block; overflow: hidden; border-radius: 10px; --water: var(--text); }
|
|
23
|
+
.sailing .sea { fill: color-mix(in srgb, var(--water) 62%, var(--bg)); }
|
|
24
|
+
.sailing .sea.back { fill: color-mix(in srgb, var(--water) 32%, var(--bg)); }
|
|
25
|
+
.sailing .boat { fill: var(--text); transform-origin: 15px 14px; animation: ride 2.4s ease-in-out infinite; }
|
|
26
|
+
.sailing .wave { animation: roll 3.2s linear infinite; }
|
|
27
|
+
.sailing .wave.slow { animation-duration: 5.6s; }
|
|
28
|
+
.trail { --row: 24px; width: 320px; height: calc(var(--row) * 3); overflow: hidden; text-align: center; }
|
|
29
|
+
.track { display: flex; flex-direction: column; transition: transform 420ms var(--ease); }
|
|
30
|
+
.track span { height: var(--row); line-height: var(--row); font-size: 13px; color: var(--text-3); opacity: 0; white-space: nowrap; transition: opacity 420ms var(--ease), color 420ms var(--ease); }
|
|
31
|
+
.track .d2 { opacity: .45; }
|
|
32
|
+
.track .d1 { opacity: 1; }
|
|
33
|
+
.track .now { opacity: 1; color: var(--text); }
|
|
34
|
+
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
|
|
35
|
+
@keyframes roll { to { transform: translateX(-16px); } }
|
|
36
|
+
@keyframes ride { 0%, 100% { transform: translateY(-1px) rotate(-5deg); } 50% { transform: translateY(1px) rotate(5deg); } }
|
|
37
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
38
|
+
`;
|
|
39
|
+
// one wave is a crest and a trough, 16 units; long enough to still fill the view after rolling a full wave
|
|
40
|
+
const WAVE = "q4-2.5 8 0t8 0 8 0 8 0 8 0 8 0 8 0 8 0 8 0 8 0";
|
|
41
|
+
// the chat's boat, drawn larger: the same shapes and motion, so building the tour and the agent at work look alike
|
|
42
|
+
const BOAT = `<svg class="sailing" width="96" height="67" viewBox="0 -3 30 21" aria-hidden="true">
|
|
43
|
+
<path class="sea back wave slow" d="M-8 12.5${WAVE}V18H-8Z"/><path class="boat" d="M13 3v11h7.5Z"/><path class="sea wave" d="M0 14${WAVE}V18H0Z"/>
|
|
44
|
+
</svg>`;
|
|
45
|
+
const PR_ICON = `<svg class="i" viewBox="0 0 16 16"><circle cx="4" cy="3.5" r="1.5"/><circle cx="4" cy="12.5" r="1.5"/><circle cx="12" cy="12.5" r="1.5"/><path d="M4 5v6M12 11V6.5A2 2 0 0 0 10 4.5H7.5M9 3l-1.5 1.5L9 6"/></svg>`;
|
|
46
|
+
// polls the server: the tour when it is built, else each newly reported stage slides in at the bottom of the trail
|
|
47
|
+
const SCRIPT = `
|
|
48
|
+
var SUN = '<svg class="i" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.75"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1.06 1.06M11.54 11.54l1.06 1.06M3.4 12.6l1.06-1.06M11.54 4.46l1.06-1.06"/></svg>';
|
|
49
|
+
var MOON = '<svg class="i" viewBox="0 0 16 16"><path d="M13.5 9.6A5.5 5.5 0 0 1 6.4 2.5a5.5 5.5 0 1 0 7.1 7.1Z"/></svg>';
|
|
50
|
+
var root = document.documentElement, btn = document.getElementById("theme");
|
|
51
|
+
function paint() { var dark = root.dataset.theme === "dark"; btn.innerHTML = dark ? SUN : MOON; btn.title = dark ? "Light theme" : "Dark theme"; btn.setAttribute("aria-label", btn.title); }
|
|
52
|
+
btn.onclick = function () { root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark"; try { localStorage.setItem("review-theme", root.dataset.theme); } catch (e) {} paint(); };
|
|
53
|
+
paint();
|
|
54
|
+
var track = document.getElementById("track"), shown = [];
|
|
55
|
+
function render(stages) {
|
|
56
|
+
if (stages.length === shown.length) return;
|
|
57
|
+
for (var i = shown.length; i < stages.length; i++) { var s = document.createElement("span"); s.textContent = stages[i]; track.appendChild(s); }
|
|
58
|
+
shown = stages.slice();
|
|
59
|
+
var at = shown.length - 1;
|
|
60
|
+
[].forEach.call(track.children, function (el, i) { el.className = i === at ? "now" : i === at - 1 ? "d1" : i === at - 2 ? "d2" : ""; });
|
|
61
|
+
track.style.transform = "translateY(calc(var(--row) * " + -Math.max(0, at - 2) + "))";
|
|
62
|
+
}
|
|
63
|
+
function tick() { fetch("api/status").then(function (r) { return r.json(); }).then(function (s) { if (s.app) return location.reload(); render(["Starting the agent"].concat(s.progress || [])); }, function () {}); }
|
|
64
|
+
render(["Starting the agent"]); tick(); setInterval(tick, 2000);
|
|
65
|
+
`;
|
|
66
|
+
export function settingUpPage(pr) {
|
|
67
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Review</title>
|
|
68
|
+
<script>try{var t=localStorage.getItem("review-theme")}catch(e){}document.documentElement.dataset.theme=t==="light"||t==="dark"?t:matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"</script>
|
|
69
|
+
<style>${STYLE}</style></head><body>
|
|
70
|
+
${pr ? `<div class="corner"><span class="r">${PR_ICON} ${esc(pr.ref)}</span><b>${esc(pr.title)}</b></div>` : ""}
|
|
71
|
+
<button class="icon-btn" id="theme"></button>
|
|
72
|
+
<main>${BOAT}<div class="trail" role="status" aria-live="polite"><div class="track" id="track"></div></div></main>
|
|
73
|
+
<script>${SCRIPT}</script></body></html>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Box } from "../boxes.ts";
|
|
2
|
+
import type { PrRepo } from "./target.ts";
|
|
3
|
+
import type { Config } from "../types.ts";
|
|
4
|
+
export declare const REVIEW_PORT: number;
|
|
5
|
+
export declare const INSTANCE_PORTS: number[];
|
|
6
|
+
export interface ReviewStarted {
|
|
7
|
+
box: Box;
|
|
8
|
+
url: string;
|
|
9
|
+
pr: string;
|
|
10
|
+
title: string;
|
|
11
|
+
env: "pr" | "local" | "none";
|
|
12
|
+
}
|
|
13
|
+
export type ReviewSource = {
|
|
14
|
+
pr: {
|
|
15
|
+
repo: PrRepo;
|
|
16
|
+
number: number;
|
|
17
|
+
};
|
|
18
|
+
} | {
|
|
19
|
+
changes: {
|
|
20
|
+
root: string;
|
|
21
|
+
base?: string;
|
|
22
|
+
note?: string;
|
|
23
|
+
repo?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export declare function startReview(cfg: Config, opts: {
|
|
27
|
+
source: ReviewSource;
|
|
28
|
+
local?: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
agent: string;
|
|
31
|
+
onPhase?: (text: string) => void;
|
|
32
|
+
}): Promise<ReviewStarted>;
|