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,100 @@
1
+ // The PR as bottleship fetched it, written into src/data (@data) when the box was set up.
2
+ import raw from "@data/pr.json";
3
+ import box from "@data/box.json";
4
+ import diffText from "@data/pr.diff?raw";
5
+ import reviewComments from "@data/review-comments.json";
6
+
7
+
8
+ export interface Person { login: string; is_bot?: boolean }
9
+ export interface PrFile { path: string; additions: number; deletions: number }
10
+ export interface GhComment {
11
+ id: number;
12
+ path: string;
13
+ // null when the lines it was on have changed since
14
+ line: number | null;
15
+ original_line?: number | null;
16
+ side?: "LEFT" | "RIGHT";
17
+ start_line?: number | null;
18
+ start_side?: "LEFT" | "RIGHT" | null;
19
+ in_reply_to_id?: number;
20
+ body: string;
21
+ user: { login: string; type?: string };
22
+ created_at: string;
23
+ html_url?: string;
24
+ resolved?: boolean;
25
+ }
26
+
27
+ export const pr = {
28
+ number: raw.number as number,
29
+ title: raw.title as string,
30
+ url: raw.url as string,
31
+ body: (raw.body as string) ?? "",
32
+ author: (raw.author as Person).login.replace(/^app\//, ""),
33
+ // local changes have a repo name but no PR
34
+ repo: ((raw as { repo?: string }).repo ?? (raw.url as string).replace(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/.*$/, "$1")).split("/").pop()!,
35
+ ref: raw.number ? `#${raw.number}` : "local changes",
36
+ baseRef: raw.baseRefName as string,
37
+ headRef: raw.headRefName as string,
38
+ // the commits the instances run: base is where the PR branched off
39
+ base: box.base.slice(0, 7),
40
+ head: /^[0-9a-f]{40}$/.test(box.head) ? box.head.slice(0, 7) : box.head,
41
+ files: (raw.files as PrFile[]).map((f) => ({ path: f.path, add: f.additions, del: f.deletions })),
42
+ comments: raw.comments as unknown[],
43
+ reviews: raw.reviews as unknown[],
44
+ // inline comments already on GitHub, as its REST API returns them, with resolved added by bottleship
45
+ reviewComments: reviewComments as GhComment[],
46
+ };
47
+
48
+ // the title without a conventional-commit prefix, capitalised
49
+ export const shortTitle = pr.title.replace(/^\w+(\([^)]*\))?!?:\s*/, "").replace(/^./, (c) => c.toUpperCase());
50
+
51
+ export const total = pr.files.reduce((t, f) => ({ add: t.add + f.add, del: t.del + f.del }), { add: 0, del: 0 });
52
+
53
+ // n is the line in the new file, o in the old one; a removed row sits at the next new line (at)
54
+ export interface Row { t: "a" | "d" | "c"; n?: number; o?: number; at?: number; s: string }
55
+
56
+ // a unified diff split per file
57
+ function splitDiff(text: string): Record<string, string> {
58
+ const out: Record<string, string> = {};
59
+ for (const part of text.split(/^diff --git /m).slice(1)) {
60
+ const file = part.match(/^\+\+\+ b\/(.+)$/m)?.[1] ?? part.match(/^a\/(\S+)/)?.[1];
61
+ if (file) out[file] = "diff --git " + part;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export const diffs = splitDiff(diffText);
67
+ // what the PR did to a file, read from its diff header
68
+ export type FileStatus = "added" | "deleted" | "renamed" | "modified";
69
+ export function fileStatus(path: string): FileStatus {
70
+ const d = diffs[path] ?? "";
71
+ const at = d.indexOf("\n@@");
72
+ const head = at < 0 ? d : d.slice(0, at);
73
+ return /^new file mode/m.test(head) ? "added" : /^deleted file mode/m.test(head) ? "deleted" : /^rename from/m.test(head) ? "renamed" : "modified";
74
+ }
75
+
76
+ // a file's diff as rows with new-file line numbers; removed rows sit at the next new line
77
+ export function rows(diff: string): Row[] {
78
+ const out: Row[] = [];
79
+ let n = 0;
80
+ let o = 0;
81
+ let inHunk = false;
82
+ for (const line of diff.split("\n")) {
83
+ const m = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)/);
84
+ if (m) {
85
+ o = Number(m[1]);
86
+ n = Number(m[2]);
87
+ inHunk = true;
88
+ continue;
89
+ }
90
+ if (!inHunk || line.startsWith("\\")) continue;
91
+ if (line.startsWith("diff --git ")) {
92
+ inHunk = false;
93
+ continue;
94
+ }
95
+ if (line.startsWith("+")) out.push({ t: "a", n: n++, s: line.slice(1) });
96
+ else if (line.startsWith("-")) out.push({ t: "d", o: o++, at: n, s: line.slice(1) });
97
+ else out.push({ t: "c", n: n++, o: o++, s: line.slice(1) });
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,298 @@
1
+ // The pull request itself, as it stands on GitHub: its state, what stands between it and a merge, the checks and
2
+ // action runs on its head, who reviewed it, and merging it when the reviewer's account may. Read through the review
3
+ // server (the box's gh), which merges only at the head the tour was built from.
4
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
5
+ import { Body } from "./comments";
6
+ import { pr } from "./pr";
7
+ import { Icon, Section, Stat } from "./ui";
8
+
9
+ type Method = "MERGE" | "SQUASH" | "REBASE";
10
+ interface Check { name: string; workflow?: string; status: "pass" | "fail" | "pending" | "skipped" | "neutral"; url?: string; started?: string; finished?: string }
11
+ interface Status {
12
+ number: number;
13
+ title: string;
14
+ url: string;
15
+ body: string;
16
+ state: "OPEN" | "CLOSED" | "MERGED";
17
+ draft: boolean;
18
+ author?: string;
19
+ head: { ref: string; oid: string };
20
+ base: string;
21
+ additions: number;
22
+ deletions: number;
23
+ files: number;
24
+ merged?: { at?: string; by?: string };
25
+ mergeable: string;
26
+ mergeState: string;
27
+ reviewDecision?: string;
28
+ reviews: { by: string; state: string; at?: string }[];
29
+ requested: string[];
30
+ checks: Check[];
31
+ canMerge: boolean;
32
+ methods: Method[];
33
+ defaultMethod?: Method;
34
+ }
35
+
36
+ // how often the page asks again while it is open; the server answers from a short cache
37
+ const POLL = 15_000;
38
+
39
+ async function call(path: string, body?: unknown): Promise<{ ok: true; data: Status } | { ok: false; error: string }> {
40
+ try {
41
+ const r = await fetch(path, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined);
42
+ const data = await r.json().catch(() => ({}));
43
+ return r.ok ? { ok: true, data } : { ok: false, error: (data as { errors?: string[] }).errors?.join(", ") ?? `The review server answered ${r.status}.` };
44
+ } catch {
45
+ return { ok: false, error: "The review server is not answering." };
46
+ }
47
+ }
48
+
49
+ const ago = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
50
+ function when(at?: string) {
51
+ if (!at) return "";
52
+ const s = (Date.parse(at) - Date.now()) / 1000;
53
+ for (const [unit, n] of [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]] as const)
54
+ if (Math.abs(s) >= n) return ago.format(Math.round(s / n), unit);
55
+ return "just now";
56
+ }
57
+ function took(c: Check) {
58
+ if (!c.started || !c.finished) return "";
59
+ const s = Math.round((Date.parse(c.finished) - Date.parse(c.started)) / 1000);
60
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
61
+ }
62
+
63
+ const METHOD = { SQUASH: ["Squash", "Squash and merge"], MERGE: ["Merge", "Create a merge commit"], REBASE: ["Rebase", "Rebase and merge"] } as const;
64
+
65
+ type Tone = "pass" | "fail" | "pending" | "warn" | "merged" | "skipped" | "neutral";
66
+ const ICON: Record<Tone, React.ReactNode> = { pass: Icon.check, fail: Icon.cross, warn: Icon.minus, skipped: Icon.minus, neutral: Icon.minus, merged: Icon.check, pending: null };
67
+ const St = ({ tone }: { tone: Tone }) => <span className={`prp-st ${tone}`}>{ICON[tone]}</span>;
68
+
69
+ // what a merge needs, each with its state and why, as GitHub reports them
70
+ function gatesOf(s: Status): { tone: Tone; title: string; why: string }[] {
71
+ const failing = s.checks.filter((c) => c.status === "fail");
72
+ const running = s.checks.filter((c) => c.status === "pending");
73
+ const passing = s.checks.filter((c) => c.status === "pass");
74
+ const out: { tone: Tone; title: string; why: string }[] = [];
75
+ if (s.draft) out.push({ tone: "fail", title: "A draft", why: "It cannot be merged until it is marked ready for review" });
76
+ out.push(
77
+ failing.length
78
+ ? { tone: "fail", title: `${failing.length} ${failing.length === 1 ? "check" : "checks"} failing`, why: `${failing.map((c) => c.name + (c.workflow ? ` in ${c.workflow}` : "")).join(", ")}.${s.mergeState === "UNSTABLE" ? " Not required, so it does not block." : ""}` }
79
+ : running.length
80
+ ? { tone: "pending", title: "Checks running", why: running.map((c) => c.name).join(", ") }
81
+ : s.checks.length
82
+ ? { tone: "pass", title: "Checks passed", why: `${passing.length} passed` }
83
+ : { tone: "neutral", title: "No checks", why: "Nothing ran on this commit" },
84
+ );
85
+ const approvers = s.reviews.filter((r) => r.state === "APPROVED").map((r) => r.by);
86
+ const blockers = s.reviews.filter((r) => r.state === "CHANGES_REQUESTED").map((r) => r.by);
87
+ out.push(
88
+ s.reviewDecision === "CHANGES_REQUESTED"
89
+ ? { tone: "fail", title: "Changes requested", why: `By ${blockers.join(", ") || "a reviewer"}` }
90
+ : s.reviewDecision === "REVIEW_REQUIRED"
91
+ ? { tone: "fail", title: "Review required", why: s.requested.length ? `Waiting on ${s.requested.join(", ")}` : "An approving review is needed" }
92
+ : approvers.length
93
+ ? { tone: "pass", title: "Approved", why: `By ${approvers.join(", ")}` }
94
+ : { tone: "neutral", title: "No review needed", why: "The branch does not require one" },
95
+ );
96
+ out.push(
97
+ s.mergeable === "CONFLICTING" || s.mergeState === "DIRTY"
98
+ ? { tone: "fail", title: "Conflicts", why: `With ${s.base}; resolve them first` }
99
+ : s.mergeable === "UNKNOWN"
100
+ ? { tone: "pending", title: "Checking for conflicts", why: "GitHub is still working it out" }
101
+ : { tone: "pass", title: "No conflicts", why: `Merges cleanly into ${s.base}` },
102
+ );
103
+ out.push(s.mergeState === "BEHIND" ? { tone: "warn", title: "Behind", why: `${s.base} has moved on; update the branch first` } : { tone: "pass", title: "Up to date", why: `Nothing on ${s.base} it needs first` });
104
+ return out;
105
+ }
106
+
107
+ // the description, cut to a few lines with More when it runs longer
108
+ function Description({ text }: { text: string }) {
109
+ const ref = useRef<HTMLDivElement>(null);
110
+ const [long, setLong] = useState(false);
111
+ const [open, setOpen] = useState(false);
112
+ useLayoutEffect(() => {
113
+ const el = ref.current;
114
+ if (el && !open) setLong(el.scrollHeight > el.clientHeight + 1);
115
+ }, [text, open]);
116
+ if (!text.trim()) return <p className="prose prp-dim">No description.</p>;
117
+ return (
118
+ <div className="card pad prp-desc">
119
+ <div ref={ref} className={open ? undefined : "prp-clamp"}><Body text={text} /></div>
120
+ {long && <button className="show-more" onClick={() => setOpen(!open)}>{open ? "Less" : "More"}</button>}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function PullRequest() {
126
+ const [status, setStatus] = useState<Status>();
127
+ const [error, setError] = useState<string>();
128
+ const [loading, setLoading] = useState(false);
129
+ const [open, setOpen] = useState<Set<string>>();
130
+ const load = async () => {
131
+ setLoading(true);
132
+ const r = await call("api/pr");
133
+ setLoading(false);
134
+ if (r.ok) (setStatus(r.data), setError(undefined));
135
+ else setError(r.error);
136
+ };
137
+ useEffect(() => {
138
+ void load();
139
+ const t = setInterval(() => document.visibilityState === "visible" && void load(), POLL);
140
+ return () => clearInterval(t);
141
+ }, []);
142
+ if (!status)
143
+ return error ? (
144
+ <section className="view orient prp">
145
+ <h1 className="title">{pr.title}</h1>
146
+ <p className="prose post-error" role="alert">{error}</p>
147
+ </section>
148
+ ) : <Skeleton />;
149
+ const s = status;
150
+ // checks by workflow; statuses from outside a workflow, like deploys, go together
151
+ const groups = [...new Set(s.checks.map((c) => c.workflow ?? "Other"))].map((name) => ({ name, checks: s.checks.filter((c) => (c.workflow ?? "Other") === name) }));
152
+ const toneOf = (cs: Check[]): Tone => (cs.some((c) => c.status === "fail") ? "fail" : cs.some((c) => c.status === "pending") ? "pending" : cs.every((c) => c.status === "skipped") ? "skipped" : "pass");
153
+ // groups with a failure start open
154
+ const expanded = open ?? new Set(groups.filter((g) => toneOf(g.checks) === "fail").map((g) => g.name));
155
+ const toggle = (n: string) => {
156
+ const next = new Set(expanded);
157
+ next.has(n) ? next.delete(n) : next.add(n);
158
+ setOpen(next);
159
+ };
160
+ const count = (st: Check["status"]) => s.checks.filter((c) => c.status === st).length;
161
+ const sum = [count("fail") && `${count("fail")} failing`, count("pending") && `${count("pending")} running`, count("pass") && `${count("pass")} passing`, count("skipped") && `${count("skipped")} skipped`].filter(Boolean).join(", ");
162
+ return (
163
+ <section className="view prp-gates">
164
+ <div className="prp-main">
165
+ <div>
166
+ <h1 className="title">{s.title}</h1>
167
+ <div className="meta">
168
+ <span className={`chip ${s.state === "MERGED" ? "merged" : s.state === "CLOSED" ? "fail" : s.draft ? "" : "done"}`}>{s.state === "MERGED" ? "Merged" : s.state === "CLOSED" ? "Closed" : s.draft ? "Draft" : "Open"}</span>
169
+ <span>#{s.number}{s.author ? ` by ${s.author}` : ""}</span>
170
+ <span className="refs">{s.head.ref} {Icon.arrow} {s.base}</span>
171
+ <Stat add={s.additions} del={s.deletions} />
172
+ <span>{s.files} {s.files === 1 ? "file" : "files"}</span>
173
+ <a className="link" href={s.url} target="_blank" rel="noreferrer">On GitHub</a>
174
+ </div>
175
+ </div>
176
+ <Description text={s.body} />
177
+ <section>
178
+ <div className="label prp-label">Checks <span className="c">{sum}</span><button className="btn ghost sm" onClick={() => void load()} disabled={loading}>{Icon.reset} {loading ? "Checking" : "Refresh"}</button></div>
179
+ {groups.length ? groups.map((g) => (
180
+ <div className="prp-wf" key={g.name}>
181
+ <button className="prp-wf-h" aria-expanded={expanded.has(g.name)} onClick={() => toggle(g.name)}>
182
+ <span className="chev">{Icon.chevron}</span>
183
+ <St tone={toneOf(g.checks)} />
184
+ <span className="grow"><b>{g.name}</b> <span className="prp-dim">{g.checks.length} {g.checks.length === 1 ? "job" : "jobs"}</span></span>
185
+ </button>
186
+ {expanded.has(g.name) && (
187
+ <div className="prp-jobs">
188
+ {g.checks.map((c, i) => (
189
+ <div className="prp-job" key={i}>
190
+ <St tone={c.status} />
191
+ <span className="grow">{c.name}</span>
192
+ <span className="prp-dim">{c.status === "pending" ? "running" : c.status === "skipped" ? "skipped" : took(c)}</span>
193
+ {c.url && <a className="link" href={c.url} target="_blank" rel="noreferrer">Details</a>}
194
+ </div>
195
+ ))}
196
+ </div>
197
+ )}
198
+ </div>
199
+ )) : <p className="prose">No checks or action runs on this commit.</p>}
200
+ </section>
201
+ <Section label="Reviews" count={s.reviews.length || undefined}>
202
+ {s.reviews.length || s.requested.length ? (
203
+ <div className="rows">
204
+ {s.reviews.map((r) => {
205
+ const [name, tone] = REVIEW[r.state as keyof typeof REVIEW] ?? [r.state.toLowerCase(), ""];
206
+ return (
207
+ <div key={r.by} className="row">
208
+ <span className="avatar">{r.by[0]?.toUpperCase()}</span>
209
+ <span className="grow"><b>{r.by}</b></span>
210
+ <span className={`chip ${tone}`}>{name}</span>
211
+ <span className="prp-dim">{when(r.at)}</span>
212
+ </div>
213
+ );
214
+ })}
215
+ {s.requested.length > 0 && <div className="row"><span className="grow prp-dim">Waiting on {s.requested.join(", ")}</span></div>}
216
+ </div>
217
+ ) : <p className="prose">Nobody has reviewed it yet.</p>}
218
+ </Section>
219
+ </div>
220
+ <Gates s={s} onDone={setStatus} />
221
+ </section>
222
+ );
223
+ }
224
+
225
+ const REVIEW = { APPROVED: ["Approved", "ok"], CHANGES_REQUESTED: ["Changes requested", "fail"], COMMENTED: ["Commented", ""], DISMISSED: ["Dismissed", ""] } as const;
226
+
227
+ // the page's shape while GitHub answers
228
+ function Skeleton() {
229
+ const bar = (w: string, h = 12) => <span className="skel" style={{ width: w, height: h }} />;
230
+ return (
231
+ <section className="view prp-gates" aria-busy="true" aria-label="Loading the pull request">
232
+ <div className="prp-main">
233
+ <div className="skel-stack">{bar("70%", 26)}{bar("55%")}</div>
234
+ <div className="card pad prp-desc skel-stack">{bar("92%")}{bar("84%")}{bar("60%")}</div>
235
+ <div>
236
+ <div className="skel-stack" style={{ marginBottom: 12 }}>{bar("28%")}</div>
237
+ {[0, 1, 2].map((i) => <div className="prp-wf skel-row" key={i}><span className="skel skel-dot" />{bar(`${[30, 22, 26][i]}%`)}</div>)}
238
+ </div>
239
+ </div>
240
+ <aside className="prp-panel">
241
+ {bar("60%", 18)}
242
+ {[0, 1, 2, 3].map((i) => <div className="prp-gate" key={i}><span className="skel skel-dot" /><span className="skel-stack" style={{ flex: 1 }}>{bar(`${[45, 35, 40, 30][i]}%`)}{bar(`${[75, 60, 70, 55][i]}%`, 9)}</span></div>)}
243
+ <hr />
244
+ {bar("100%", 32)}
245
+ </aside>
246
+ </section>
247
+ );
248
+ }
249
+
250
+ // what a merge needs, then merging; that takes a second click, as it changes the base branch for everyone
251
+ function Gates({ s, onDone }: { s: Status; onDone: (s: Status) => void }) {
252
+ const [method, setMethod] = useState<Method | undefined>(s.defaultMethod ?? s.methods[0]);
253
+ const [confirm, setConfirm] = useState(false);
254
+ const [busy, setBusy] = useState(false);
255
+ const [error, setError] = useState<string>();
256
+ const blocked = s.draft || s.mergeable === "CONFLICTING" || s.mergeState === "DIRTY";
257
+ const merge = async () => {
258
+ if (!method) return;
259
+ setBusy(true);
260
+ setError(undefined);
261
+ const r = await call("api/pr/merge", { method });
262
+ setBusy(false);
263
+ setConfirm(false);
264
+ if (r.ok) onDone(r.data);
265
+ else setError(r.error);
266
+ };
267
+ return (
268
+ <aside className="prp-panel">
269
+ <h2>{s.state === "MERGED" ? "Merged" : s.state === "CLOSED" ? "Closed" : `Merging into ${s.base}`}</h2>
270
+ {s.state === "OPEN" && gatesOf(s).map((g) => (
271
+ <div className="prp-gate" key={g.title}><St tone={g.tone} /><span><b>{g.title}</b><span>{g.why}</span></span></div>
272
+ ))}
273
+ {s.state === "OPEN" && <hr />}
274
+ {s.state === "MERGED" ? (
275
+ <div className="prp-gate"><St tone="merged" /><span><b>Merged{s.merged?.by ? ` by ${s.merged.by}` : ""}</b><span>{when(s.merged?.at)}</span></span></div>
276
+ ) : s.state === "CLOSED" ? (
277
+ <p className="prose prp-dim">Closed without merging.</p>
278
+ ) : s.canMerge ? (
279
+ <div className="prp-acts">
280
+ {s.methods.length > 1 && (
281
+ <div className="vswitch prp-methods" role="radiogroup" aria-label="How to merge">
282
+ {s.methods.map((m) => <button key={m} role="radio" aria-checked={method === m} onClick={() => (setMethod(m), setConfirm(false))}>{METHOD[m][0]}</button>)}
283
+ </div>
284
+ )}
285
+ {error && <p className="prose post-error" role="alert">{error}</p>}
286
+ {confirm ? (
287
+ <div className="prp-confirm">
288
+ <button className="btn" disabled={busy} onClick={() => setConfirm(false)}>Cancel</button>
289
+ <button className="btn primary" disabled={busy} onClick={() => void merge()}>{busy ? "Merging" : "Confirm"}</button>
290
+ </div>
291
+ ) : (
292
+ <button className="btn primary" disabled={blocked || !method} onClick={() => setConfirm(true)}>{method ? METHOD[method][1] : "Merge"}</button>
293
+ )}
294
+ </div>
295
+ ) : <p className="prose prp-dim">Your GitHub account cannot merge in this repository.</p>}
296
+ </aside>
297
+ );
298
+ }
@@ -0,0 +1,74 @@
1
+ // What a review is: instances, an overview, chapters in tour order, and the code grouped the same way.
2
+ import { createContext, useContext, type ReactNode } from "react";
3
+ import type { ReviewEvent } from "./live";
4
+
5
+ export interface Instance {
6
+ id: string;
7
+ // short: Base, Head
8
+ label: string;
9
+ // what sets it apart, e.g. "master, Hebrew"
10
+ detail?: string;
11
+ // a port inside the box: the app's own for head, 7781 to 7784 for base and others
12
+ port: number;
13
+ path?: string;
14
+ }
15
+
16
+ export interface ChapterDef {
17
+ id: string;
18
+ title: string;
19
+ page: () => ReactNode;
20
+ }
21
+
22
+ // a part of the diff with its reason; lines are new-file line ranges
23
+ export interface Group {
24
+ id: string;
25
+ title: string;
26
+ why: string;
27
+ chapter?: string;
28
+ file: string;
29
+ lines?: [number, number][];
30
+ }
31
+
32
+ export interface Review {
33
+ instances: Instance[];
34
+ overview: () => ReactNode;
35
+ chapters: ChapterDef[];
36
+ groups: Group[];
37
+ }
38
+
39
+ export const defineReview = (r: Review) => r;
40
+
41
+ // the chapters the reviewer has opened this visit; the rail ticks them, Finish counts them
42
+ export const seenChapters = new Set<string>();
43
+
44
+ export type Target = "overview" | "changes" | "pr" | "finish" | { chapter: string } | { group: string };
45
+
46
+ export interface Nav {
47
+ review: Review;
48
+ go: (t: Target) => void;
49
+ // the chapter being shown, if any
50
+ chapter?: { def: ChapterDef; index: number };
51
+ }
52
+
53
+ export const NavContext = createContext<Nav | null>(null);
54
+
55
+ export function useNav(): Nav {
56
+ const nav = useContext(NavContext);
57
+ if (!nav) throw new Error("useNav outside the review shell");
58
+ return nav;
59
+ }
60
+
61
+ export const hash = (t: Target) =>
62
+ t === "overview" ? "#/" : t === "changes" ? "#/changes" : t === "finish" ? "#/finish" : t === "pr" ? "#/pr" : "chapter" in t ? `#/chapter/${t.chapter}` : `#/changes/${t.group}`;
63
+
64
+ interface ChapterState { step?: string; events: ReviewEvent[]; instances: Instance[] }
65
+ export const ChapterContext = createContext<ChapterState | null>(null);
66
+
67
+ // inside a chapter: its step's events since the last Run, and the instances it is about
68
+ export function useChapter(): ChapterState {
69
+ const nav = useContext(NavContext);
70
+ return useContext(ChapterContext) ?? { events: [], instances: nav?.review.instances ?? [] };
71
+ }
72
+
73
+ // the latest outcome (an ok or fail event) an instance reported
74
+ export const outcomeOf = (events: ReviewEvent[], id: string) => [...events].reverse().find((e) => e.instance === id && (e.kind === "ok" || e.kind === "fail"));
@@ -0,0 +1,30 @@
1
+ // Light or dark: follows the system until the reviewer picks one, then remembers it.
2
+ import { useState } from "react";
3
+
4
+ type Theme = "light" | "dark";
5
+ const KEY = "review-theme";
6
+ const system = matchMedia("(prefers-color-scheme: dark)");
7
+
8
+ function chosen(): Theme | null {
9
+ try {
10
+ const v = localStorage.getItem(KEY);
11
+ return v === "light" || v === "dark" ? v : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ const apply = (t: Theme) => { document.documentElement.dataset.theme = t; };
18
+ apply(chosen() ?? (system.matches ? "dark" : "light"));
19
+ system.addEventListener("change", (e) => { if (!chosen()) apply(e.matches ? "dark" : "light"); });
20
+
21
+ export function useTheme() {
22
+ const [theme, setTheme] = useState(document.documentElement.dataset.theme as Theme);
23
+ const toggle = () => {
24
+ const next: Theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
25
+ apply(next);
26
+ try { localStorage.setItem(KEY, next); } catch { /* private mode: still switches for this visit */ }
27
+ setTheme(next);
28
+ };
29
+ return { theme, toggle };
30
+ }
@@ -0,0 +1,127 @@
1
+ // Small pieces every page uses: icons, buttons, rows, labels, file rows and stats.
2
+ import type { ReactNode } from "react";
3
+ import { useNav, type Instance, type Target } from "./review";
4
+ import { useTheme } from "./theme";
5
+ import { fileStatus, pr, type FileStatus } from "./pr";
6
+
7
+ const svg = (d: ReactNode, fill = false) => (
8
+ <svg className="i" viewBox="0 0 16 16" style={fill ? { fill: "currentColor", stroke: "none" } : undefined}>{d}</svg>
9
+ );
10
+ export const Icon = {
11
+ pr: svg(<><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" /></>),
12
+ arrow: svg(<path d="M3.5 8h9M9 4.5 12.5 8 9 11.5" />),
13
+ back: svg(<path d="M12.5 8h-9M7 4.5 3.5 8 7 11.5" />),
14
+ dock: svg(<><rect x="2" y="3" width="12" height="10" rx="2" /><path d="M9.5 3v10" /></>),
15
+ float: svg(<><rect x="2" y="3" width="12" height="10" rx="2" /><rect x="8" y="8" width="4" height="3" rx=".5" /></>),
16
+ panel: svg(<><rect x="2" y="3" width="12" height="10" rx="2" /><path d="M6.5 3v10" /></>),
17
+ sun: svg(<><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" /></>),
18
+ moon: svg(<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" />),
19
+ eye: svg(<><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z" /><circle cx="8" cy="8" r="1.8" /></>),
20
+ play: svg(<path d="M5 3.3v9.4a.5.5 0 0 0 .76.43l7.5-4.7a.5.5 0 0 0 0-.86l-7.5-4.7A.5.5 0 0 0 5 3.3Z" />, true),
21
+ code: svg(<path d="M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5" />),
22
+ expand: svg(<path d="M8 2.5v11M5 5.5l3-3 3 3M5 10.5l3 3 3-3" />),
23
+ check: svg(<path d="M3.5 8.5 6.5 11.5 12.5 4.5" />),
24
+ reset: svg(<path d="M3 8a5 5 0 1 0 1.5-3.5M3 3v2.5h2.5" />),
25
+ external: svg(<path d="M6.5 3.5h-3v9h9v-3M9 3.5h3.5V7M12.5 3.5 7 9" />),
26
+ chevron: svg(<path d="M6 4l4 4-4 4" />),
27
+ cross: svg(<path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />),
28
+ minus: svg(<path d="M4 8h8" />),
29
+ plus: svg(<path d="M8 3.5v9M3.5 8h9" />),
30
+ pause: svg(<path d="M5.5 3.5v9M10.5 3.5v9" />),
31
+ cursorPlus: svg(<><path d="M2.5 2 9.5 5.5 6.25 6.5 5.25 9.75Z" /><path d="M12 9v5M9.5 11.5h5" /></>),
32
+ cursor: svg(<path d="M3.5 2.5 12.5 7 8.25 8.25 7 12.5Z" />),
33
+ page: svg(<><path d="M4 2.5h5.5L12 5v8.5H4Z" /><path d="M6.5 8h3M6.5 10.5h3" /></>),
34
+ quote: svg(<path d="M6 3h4M6 13h4M8 3v10" />),
35
+ send: svg(<path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7" />),
36
+ suggest: svg(<path d="M8 2.5v6M5 5.5h6M5 12.5h6" />),
37
+ comment: svg(<path d="M4.5 3h7A1.5 1.5 0 0 1 13 4.5v5a1.5 1.5 0 0 1-1.5 1.5H7.5l-3 2.5V11A1.5 1.5 0 0 1 3 9.5v-5A1.5 1.5 0 0 1 4.5 3Z" />),
38
+ };
39
+
40
+
41
+ export function Stat({ add, del }: { add: number; del: number }) {
42
+ return (
43
+ <span className="stat">
44
+ {add > 0 && <span className="plus">+{add}</span>}
45
+ {del > 0 && <span className="minus">{"−"}{del}</span>}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ const STATUS: Record<FileStatus, [ReactNode, string]> = {
51
+ added: [svg(<path d="M8 4v8M4 8h8" />), "Added"],
52
+ deleted: [svg(<path d="M4 8h8" />), "Deleted"],
53
+ renamed: [svg(<path d="M3.5 8h9M9 4.5 12.5 8 9 11.5" />), "Renamed"],
54
+ modified: [svg(<circle cx="8" cy="8" r="2.25" />, true), "Changed"],
55
+ };
56
+
57
+ // added, deleted, renamed or changed
58
+ export function FileMark({ path }: { path: string }) {
59
+ const status = fileStatus(path);
60
+ const [icon, label] = STATUS[status];
61
+ return <span className={`fmark ${status}`} title={label} aria-label={label}>{icon}</span>;
62
+ }
63
+
64
+ // five blocks: how many fill is the file's size against the PR's largest file, split green and red by added and deleted
65
+ const largest = Math.max(1, ...pr.files.map((f) => f.add + f.del));
66
+ export function SizeBar({ add, del }: { add: number; del: number }) {
67
+ const filled = add + del ? Math.max(1, Math.round((5 * (add + del)) / largest)) : 0;
68
+ const green = Math.round((filled * add) / (add + del || 1));
69
+ return (
70
+ <span className="sbar" title={`+${add} −${del}`}>
71
+ {[0, 1, 2, 3, 4].map((i) => <i key={i} className={i < green ? "a" : i < filled ? "d" : ""} />)}
72
+ </span>
73
+ );
74
+ }
75
+
76
+ export function FileLine({ path, add, del }: { path: string; add?: number; del?: number }) {
77
+ const name = path.split("/").pop()!;
78
+ return (
79
+ <>
80
+ {add !== undefined ? <SizeBar add={add} del={del ?? 0} /> : <FileMark path={path} />}
81
+ <span className="fname">{name}</span>
82
+ <span className="fpath grow">{path.slice(0, -name.length)}</span>
83
+ {add !== undefined && <Stat add={add} del={del ?? 0} />}
84
+ </>
85
+ );
86
+ }
87
+
88
+ export function Label({ children, count }: { children: ReactNode; count?: number }) {
89
+ return <div className="label">{children}{count !== undefined && <span className="c">{count}</span>}</div>;
90
+ }
91
+
92
+ export function Section({ label, count, children }: { label: string; count?: number; children: ReactNode }) {
93
+ return <div><Label count={count}>{label}</Label>{children}</div>;
94
+ }
95
+
96
+ // a card row, a link when it has a target
97
+ export function Row({ to, toLabel, tall, children }: { to?: Target; toLabel?: string; tall?: boolean; children: ReactNode }) {
98
+ const { go } = useNav();
99
+ return (
100
+ <div className={`row${tall ? " tall" : ""}`}>
101
+ {children}
102
+ {to && <button className="link" onClick={() => go(to)}>{toLabel ?? "Open"}</button>}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ export const Rows = ({ children }: { children: ReactNode }) => <div className="rows">{children}</div>;
108
+
109
+ // an instance's short name, and what sets it apart
110
+ export function InstanceName({ inst }: { inst: Instance }) {
111
+ return <span className="iname"><b>{inst.label}</b>{inst.detail && <span>{inst.detail}</span>}</span>;
112
+ }
113
+
114
+ const arrow = <svg className="refs-arrow" viewBox="0 0 16 8" aria-hidden="true"><path d="M1 4h13M11 1l3 3-3 3" /></svg>;
115
+
116
+ // two refs with a thin arrow between them: a branch into the branch it merges into, or base and head commits
117
+ export function Refs({ from, to, label }: { from: ReactNode; to: ReactNode; label?: string }) {
118
+ return <span className="refs" aria-label={label}><span>{from}</span>{arrow}<span>{to}</span></span>;
119
+ }
120
+
121
+ // switches between light and dark, shown as the theme it switches to
122
+ export function ThemeToggle() {
123
+ const { theme, toggle } = useTheme();
124
+ const dark = theme === "dark";
125
+ const label = dark ? "Light theme" : "Dark theme";
126
+ return <button className="icon-btn theme" onClick={toggle} title={label} aria-label={label}>{dark ? Icon.sun : Icon.moon}</button>;
127
+ }
@@ -0,0 +1,6 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./App";
3
+ import review from "./review";
4
+ import "./kit/kit.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(<App review={review} />);
@@ -0,0 +1,22 @@
1
+ // The review of this PR. Replace this starter with the tour: read KIT.md, and see examples/rply-16.tsx for a complete one.
2
+ import { Chapter, defineReview, Lead, Overview, pr } from "./kit";
3
+
4
+ export default defineReview({
5
+ instances: [
6
+ { id: "base", label: "Base", detail: pr.baseRef, port: 7781 },
7
+ { id: "head", label: "Head", detail: "this PR", port: 7782 },
8
+ ],
9
+ overview: () => (
10
+ <Overview>
11
+ <Lead>{pr.body.split("\n\n")[0] || pr.title}</Lead>
12
+ </Overview>
13
+ ),
14
+ chapters: [
15
+ {
16
+ id: "side-by-side",
17
+ title: "Base and head side by side",
18
+ page: () => <Chapter title="Base and head side by side">The app before and after this PR.</Chapter>,
19
+ },
20
+ ],
21
+ groups: [],
22
+ });