bottleship 0.1.0-prototype.1 → 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 (165) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +118 -117
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -84
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +2 -4
  9. package/dist/src/init.js +92 -228
  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/types.d.ts +1 -71
  49. package/dist/src/ui.d.ts +6 -1
  50. package/dist/src/ui.js +22 -23
  51. package/dist/src/util.d.ts +11 -0
  52. package/dist/src/util.js +40 -0
  53. package/package.json +7 -14
  54. package/presets/agents/claude.json +1 -5
  55. package/presets/agents/codex.json +1 -10
  56. package/presets/agents/cursor.json +1 -9
  57. package/presets/agents/opencode.json +1 -14
  58. package/presets/providers/docker/create +12 -0
  59. package/presets/providers/docker/destroy +2 -0
  60. package/presets/providers/docker/exec +2 -0
  61. package/presets/providers/docker/list +4 -0
  62. package/presets/providers/docker/status +2 -0
  63. package/presets/providers/docker/url +4 -0
  64. package/review-app/KIT.md +106 -0
  65. package/review-app/WRITING.md +93 -0
  66. package/review-app/examples/rply-16.tsx +98 -0
  67. package/review-app/index.html +15 -0
  68. package/review-app/package-lock.json +2539 -0
  69. package/review-app/package.json +24 -0
  70. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  71. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  93. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  94. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  95. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  96. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  97. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  98. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  99. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  101. package/review-app/playground/gallery.tsx +127 -0
  102. package/review-app/playground/index.html +15 -0
  103. package/review-app/playground/main.tsx +26 -0
  104. package/review-app/playground/mock.ts +215 -0
  105. package/review-app/playground/playground.css +18 -0
  106. package/review-app/playground/vite.config.ts +32 -0
  107. package/review-app/scripts/check.cjs +47 -0
  108. package/review-app/scripts/clip.cjs +104 -0
  109. package/review-app/scripts/shot.cjs +66 -0
  110. package/review-app/src/App.tsx +132 -0
  111. package/review-app/src/data/box.json +1 -0
  112. package/review-app/src/data/hooks.diff +28 -0
  113. package/review-app/src/data/pr.diff +114 -0
  114. package/review-app/src/data/pr.json +68 -0
  115. package/review-app/src/data/review-comments.json +1 -0
  116. package/review-app/src/kit/agent.tsx +287 -0
  117. package/review-app/src/kit/chapter.tsx +178 -0
  118. package/review-app/src/kit/code.tsx +217 -0
  119. package/review-app/src/kit/comments.tsx +377 -0
  120. package/review-app/src/kit/finish.tsx +274 -0
  121. package/review-app/src/kit/flow.tsx +184 -0
  122. package/review-app/src/kit/index.ts +10 -0
  123. package/review-app/src/kit/kit.css +696 -0
  124. package/review-app/src/kit/live.ts +91 -0
  125. package/review-app/src/kit/media.tsx +348 -0
  126. package/review-app/src/kit/overview.tsx +161 -0
  127. package/review-app/src/kit/pr.ts +100 -0
  128. package/review-app/src/kit/pullrequest.tsx +298 -0
  129. package/review-app/src/kit/review.ts +74 -0
  130. package/review-app/src/kit/theme.ts +30 -0
  131. package/review-app/src/kit/ui.tsx +127 -0
  132. package/review-app/src/main.tsx +6 -0
  133. package/review-app/src/review.tsx +22 -0
  134. package/review-app/tsconfig.json +17 -0
  135. package/review-app/vite.config.ts +12 -0
  136. package/dist/presets/providers/command.d.ts +0 -3
  137. package/dist/presets/providers/command.js +0 -39
  138. package/dist/presets/providers/daytona.d.ts +0 -3
  139. package/dist/presets/providers/daytona.js +0 -173
  140. package/dist/presets/providers/docker.d.ts +0 -3
  141. package/dist/presets/providers/docker.js +0 -31
  142. package/dist/presets/providers/e2b.d.ts +0 -3
  143. package/dist/presets/providers/e2b.js +0 -124
  144. package/dist/presets/providers/exe.d.ts +0 -3
  145. package/dist/presets/providers/exe.js +0 -28
  146. package/dist/presets/providers/http.example.d.ts +0 -3
  147. package/dist/presets/providers/http.example.js +0 -47
  148. package/dist/presets/providers/local.d.ts +0 -3
  149. package/dist/presets/providers/local.js +0 -52
  150. package/dist/presets/providers/modal.d.ts +0 -3
  151. package/dist/presets/providers/modal.js +0 -117
  152. package/dist/presets/providers/ssh.d.ts +0 -3
  153. package/dist/presets/providers/ssh.js +0 -49
  154. package/dist/presets/providers/util.d.ts +0 -13
  155. package/dist/presets/providers/util.js +0 -102
  156. package/presets/providers/command.ts +0 -43
  157. package/presets/providers/daytona.ts +0 -181
  158. package/presets/providers/docker.ts +0 -38
  159. package/presets/providers/e2b.ts +0 -130
  160. package/presets/providers/exe.ts +0 -35
  161. package/presets/providers/http.example.ts +0 -64
  162. package/presets/providers/local.ts +0 -56
  163. package/presets/providers/modal.ts +0 -121
  164. package/presets/providers/ssh.ts +0 -54
  165. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,189 @@
1
+ // The events review hooks and step scripts post to the review server, validated so the agent gets errors it can act on.
2
+ // The review app has its own copy of this type in review-app/src/kit/live.ts.
3
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
+ const KINDS = ["request", "hook", "ok", "fail"];
5
+ const STATUSES = ["ok", "fail", "neutral"];
6
+ function str(e, v, path, optional = false) {
7
+ if (v === undefined && optional)
8
+ return;
9
+ if (typeof v !== "string" || !v.trim())
10
+ e.push(`${path}: expected a non-empty string`);
11
+ }
12
+ const status = (e, v, path) => STATUSES.includes(v) || void e.push(`${path}: expected one of ${STATUSES.join(", ")}`);
13
+ export function validateEvent(v) {
14
+ const e = [];
15
+ if (!isObj(v))
16
+ return ["event: expected an object"];
17
+ str(e, v.instance, "instance");
18
+ if (!KINDS.includes(v.kind))
19
+ e.push(`kind: expected one of ${KINDS.join(", ")}`);
20
+ str(e, v.text, "text");
21
+ str(e, v.at, "at", true);
22
+ if (v.step !== undefined)
23
+ e.push("step: set by the server, leave it out");
24
+ if (v.flow !== undefined) {
25
+ if (!isObj(v.flow))
26
+ e.push("flow: expected an object");
27
+ else {
28
+ const f = v.flow;
29
+ str(e, f.id, "flow.id");
30
+ if (f.edge !== undefined && !(Array.isArray(f.edge) && f.edge.length === 2 && f.edge.every((n) => typeof n === "string")))
31
+ e.push("flow.edge: expected [from, to]");
32
+ str(e, f.node, "flow.node", true);
33
+ if (f.status !== undefined)
34
+ status(e, f.status, "flow.status");
35
+ str(e, f.label, "flow.label", true);
36
+ }
37
+ }
38
+ if (v.overlay !== undefined) {
39
+ if (!isObj(v.overlay))
40
+ e.push("overlay: expected an object");
41
+ else {
42
+ str(e, v.overlay.title, "overlay.title");
43
+ str(e, v.overlay.body, "overlay.body", true);
44
+ status(e, v.overlay.status, "overlay.status");
45
+ }
46
+ }
47
+ return e;
48
+ }
49
+ const SIDES = ["LEFT", "RIGHT"];
50
+ // GitHub's limit on a comment
51
+ export const MAX_COMMENT = 65_536;
52
+ const FIELDS = ["to", "mode", "body", "path", "side", "line", "startSide", "startLine", "topic", "quotes", "inReplyTo", "replyTo"];
53
+ // what a message quotes: a few things, each up to a few screens of code
54
+ const MAX_QUOTES = 8;
55
+ const MAX_QUOTE = 8000;
56
+ const line = (e, v, path) => (Number.isInteger(v) && v > 0) || void e.push(`${path}: expected a line number from 1`);
57
+ const body = (e, v) => {
58
+ str(e, v, "body");
59
+ if (typeof v === "string" && v.length > MAX_COMMENT)
60
+ e.push(`body: at most ${MAX_COMMENT} characters`);
61
+ };
62
+ export function validateComment(v) {
63
+ const e = [];
64
+ if (!isObj(v))
65
+ return ["comment: expected an object"];
66
+ for (const k of Object.keys(v))
67
+ if (!FIELDS.includes(k))
68
+ e.push(`${k}: not a comment field`);
69
+ if (v.to !== "github" && v.to !== "agent")
70
+ e.push("to: expected github or agent");
71
+ body(e, v.body);
72
+ const onLine = v.path !== undefined || v.side !== undefined || v.line !== undefined;
73
+ if (onLine) {
74
+ str(e, v.path, "path");
75
+ if (typeof v.path === "string" && (v.path.length > 1000 || v.path.startsWith("/") || v.path.split("/").includes("..")))
76
+ e.push("path: expected a file path in the repo");
77
+ if (!SIDES.includes(v.side))
78
+ e.push("side: expected LEFT or RIGHT");
79
+ line(e, v.line, "line");
80
+ if ((v.startLine === undefined) !== (v.startSide === undefined))
81
+ e.push("startLine, startSide: give both or neither");
82
+ if (v.startLine !== undefined) {
83
+ line(e, v.startLine, "startLine");
84
+ if (!SIDES.includes(v.startSide))
85
+ e.push("startSide: expected LEFT or RIGHT");
86
+ }
87
+ }
88
+ if (v.topic !== undefined) {
89
+ if (!isObj(v.topic))
90
+ e.push("topic: expected an object");
91
+ else {
92
+ str(e, v.topic.label, "topic.label");
93
+ str(e, v.topic.chapter, "topic.chapter", true);
94
+ if (v.topic.chat !== undefined && v.topic.chat !== true)
95
+ e.push("topic.chat: expected true");
96
+ for (const k of Object.keys(v.topic))
97
+ if (!["label", "chapter", "chat"].includes(k))
98
+ e.push(`topic.${k}: not a topic field`);
99
+ }
100
+ }
101
+ if (v.quotes !== undefined) {
102
+ if (!Array.isArray(v.quotes) || v.quotes.length > MAX_QUOTES)
103
+ e.push(`quotes: expected a list of at most ${MAX_QUOTES}`);
104
+ else
105
+ v.quotes.forEach((q, i) => {
106
+ const at = `quotes[${i}]`;
107
+ if (!isObj(q))
108
+ return void e.push(`${at}: expected an object`);
109
+ str(e, q.text, `${at}.text`);
110
+ if (typeof q.text === "string" && q.text.length > MAX_QUOTE)
111
+ e.push(`${at}.text: at most ${MAX_QUOTE} characters`);
112
+ str(e, q.where, `${at}.where`, true);
113
+ if (q.kind !== undefined && q.kind !== "selection" && q.kind !== "comment")
114
+ e.push(`${at}.kind: expected selection or comment`);
115
+ for (const k of Object.keys(q))
116
+ if (!["text", "where", "kind"].includes(k))
117
+ e.push(`${at}.${k}: not a quote field`);
118
+ });
119
+ }
120
+ if (v.inReplyTo !== undefined && !(Number.isInteger(v.inReplyTo) && v.inReplyTo > 0))
121
+ e.push("inReplyTo: expected the id of a GitHub comment");
122
+ if (v.replyTo !== undefined && !(typeof v.replyTo === "string" && /^c\d+$/.test(v.replyTo)))
123
+ e.push("replyTo: expected the id of a comment here");
124
+ if (v.inReplyTo !== undefined && !onLine)
125
+ e.push("inReplyTo: give the thread's path, side and line too");
126
+ if (v.to === "github") {
127
+ if (!onLine)
128
+ e.push("where: give a line (path, side, line)");
129
+ if (v.mode !== "single" && v.mode !== "review")
130
+ e.push("mode: expected single or review");
131
+ if (v.topic !== undefined || v.replyTo !== undefined || v.quotes !== undefined)
132
+ e.push("to: GitHub takes comments on lines and replies in its own threads only");
133
+ }
134
+ else {
135
+ // the agent is reached through the chat only
136
+ if (!(isObj(v.topic) && v.topic.chat === true))
137
+ e.push("topic: a message to the agent is a chat message, with topic.chat");
138
+ if (onLine || v.inReplyTo !== undefined)
139
+ e.push("to: the agent takes chat messages, not comments on lines");
140
+ if (v.mode !== undefined)
141
+ e.push("mode: only for comments to GitHub");
142
+ }
143
+ return e;
144
+ }
145
+ // an agent's answer to a comment sent to it
146
+ export function validateAnswer(v) {
147
+ const e = [];
148
+ if (!isObj(v))
149
+ return ["answer: expected an object"];
150
+ for (const k of Object.keys(v))
151
+ if (k !== "body" && k !== "commit")
152
+ e.push(`${k}: not an answer field`);
153
+ body(e, v.body);
154
+ if (v.commit !== undefined && !(typeof v.commit === "string" && /^[0-9a-f]{7,40}$/.test(v.commit)))
155
+ e.push("commit: expected a commit sha");
156
+ return e;
157
+ }
158
+ // how the reviewer means to finish: the verdict and a summary for the top of the review
159
+ export const EVENTS = ["COMMENT", "APPROVE", "REQUEST_CHANGES"];
160
+ export function validateVerdict(v) {
161
+ const e = [];
162
+ if (!isObj(v))
163
+ return ["verdict: expected an object"];
164
+ for (const k of Object.keys(v))
165
+ if (k !== "event" && k !== "body")
166
+ e.push(`${k}: not a verdict field`);
167
+ if (v.event !== undefined && !EVENTS.includes(v.event))
168
+ e.push(`event: expected one of ${EVENTS.join(", ")}`);
169
+ if (v.body !== undefined && typeof v.body !== "string")
170
+ e.push("body: expected a string");
171
+ if (typeof v.body === "string" && v.body.length > MAX_COMMENT)
172
+ e.push(`body: at most ${MAX_COMMENT} characters`);
173
+ return e;
174
+ }
175
+ // where the agent is in setting up the review, in the order the prompt walks it through; the loading page shows them
176
+ export const STAGES = {
177
+ read: "Reading the change",
178
+ instances: "Starting base and head",
179
+ hooks: "Adding review hooks",
180
+ steps: "Writing the steps",
181
+ shots: "Taking screenshots",
182
+ tour: "Writing the tour",
183
+ check: "Checking every page",
184
+ };
185
+ export function validateProgress(v) {
186
+ if (!isObj(v))
187
+ return ["progress: expected an object"];
188
+ return Object.hasOwn(STAGES, v.stage) ? [] : [`stage: expected one of ${Object.keys(STAGES).join(", ")}`];
189
+ }
@@ -0,0 +1,120 @@
1
+ import type { EVENTS, Side } from "./contract.ts";
2
+ export interface GhRepo {
3
+ owner: string;
4
+ name: string;
5
+ hostname?: string;
6
+ }
7
+ export declare function parsePrUrl(url: string): {
8
+ repo: GhRepo;
9
+ number: number;
10
+ } | undefined;
11
+ export declare function graphql<T>(repo: GhRepo, query: string, variables: Record<string, unknown>, o?: {
12
+ missing?: boolean;
13
+ }): Promise<T>;
14
+ export interface ReviewThread {
15
+ id: string;
16
+ root: number;
17
+ resolved: boolean;
18
+ }
19
+ export declare function reviewThreads(repo: GhRepo, number: number): Promise<ReviewThread[]>;
20
+ export interface GhRef {
21
+ node: string;
22
+ id: number;
23
+ url?: string;
24
+ }
25
+ export interface PendingComment extends GhRef {
26
+ body: string;
27
+ path: string;
28
+ line: number | null;
29
+ startLine: number | null;
30
+ side: Side;
31
+ startSide: Side | null;
32
+ replyTo?: number;
33
+ at: string;
34
+ }
35
+ export interface Pending {
36
+ pr: string;
37
+ review?: {
38
+ id: string;
39
+ url: string;
40
+ comments: PendingComment[];
41
+ };
42
+ }
43
+ export declare function pendingReview(repo: GhRepo, number: number): Promise<Pending>;
44
+ export declare function startPendingReview(repo: GhRepo, pr: string, head?: string): Promise<{
45
+ id: string;
46
+ url: string;
47
+ }>;
48
+ export interface LineAt {
49
+ path: string;
50
+ side: Side;
51
+ line: number;
52
+ startSide?: Side;
53
+ startLine?: number;
54
+ }
55
+ export declare function addReviewThread(repo: GhRepo, review: string, at: LineAt, body: string): Promise<GhRef & {
56
+ thread: string;
57
+ }>;
58
+ export declare function addReviewReply(repo: GhRepo, review: string, thread: string, body: string): Promise<GhRef>;
59
+ export declare function postSingle(repo: GhRepo, number: number, head: string, at: LineAt, body: string): Promise<GhRef>;
60
+ export declare function postSingleReply(repo: GhRepo, number: number, root: number, body: string): Promise<GhRef>;
61
+ export declare function editGhComment(repo: GhRepo, node: string, body: string): Promise<void>;
62
+ export declare function deleteGhComment(repo: GhRepo, node: string): Promise<void>;
63
+ export declare function commentStates(repo: GhRepo, nodes: string[]): Promise<Record<string, "PENDING" | "SUBMITTED" | null>>;
64
+ export declare function submitReview(repo: GhRepo, o: {
65
+ pr: string;
66
+ review?: string;
67
+ head?: string;
68
+ event: (typeof EVENTS)[number];
69
+ body?: string;
70
+ }): Promise<string>;
71
+ export type MergeMethod = "MERGE" | "SQUASH" | "REBASE";
72
+ export interface Check {
73
+ name: string;
74
+ workflow?: string;
75
+ status: "pass" | "fail" | "pending" | "skipped" | "neutral";
76
+ url?: string;
77
+ started?: string;
78
+ finished?: string;
79
+ }
80
+ export interface PrStatus {
81
+ id: string;
82
+ number: number;
83
+ title: string;
84
+ url: string;
85
+ body: string;
86
+ state: "OPEN" | "CLOSED" | "MERGED";
87
+ draft: boolean;
88
+ author?: string;
89
+ head: {
90
+ ref: string;
91
+ oid: string;
92
+ };
93
+ base: string;
94
+ additions: number;
95
+ deletions: number;
96
+ files: number;
97
+ merged?: {
98
+ at?: string;
99
+ by?: string;
100
+ };
101
+ mergeable: string;
102
+ mergeState: string;
103
+ reviewDecision?: string;
104
+ reviews: {
105
+ by: string;
106
+ state: string;
107
+ at?: string;
108
+ }[];
109
+ requested: string[];
110
+ checks: Check[];
111
+ canMerge: boolean;
112
+ methods: MergeMethod[];
113
+ defaultMethod?: MergeMethod;
114
+ }
115
+ export declare function prStatus(repo: GhRepo, number: number): Promise<PrStatus>;
116
+ export declare function mergePr(repo: GhRepo, o: {
117
+ id: string;
118
+ method: MergeMethod;
119
+ head: string;
120
+ }): Promise<void>;
@@ -0,0 +1,160 @@
1
+ // GitHub through the gh CLI and whatever login it has: review threads when a review starts (on this machine), and the
2
+ // reviewer's comments and review as they write them (in the review box, with its GH_TOKEN).
3
+ import { run } from "../util.js";
4
+ // a PR's link, e.g. https://github.com/acme/rply/pull/18, as the repo and number gh needs
5
+ export function parsePrUrl(url) {
6
+ const m = url.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
7
+ if (!m)
8
+ return undefined;
9
+ const host = m[1].toLowerCase();
10
+ return { repo: { owner: m[2], name: m[3], ...(host !== "github.com" && { hostname: host }) }, number: Number(m[4]) };
11
+ }
12
+ const host = (repo) => (repo.hostname ? ["--hostname", repo.hostname] : []);
13
+ async function gh(args, stdin) {
14
+ try {
15
+ return await run("gh", args, { check: false, stdin });
16
+ }
17
+ catch {
18
+ throw new Error("the gh CLI is not installed");
19
+ }
20
+ }
21
+ // variables go as JSON so nothing is coerced; throws with GitHub's own message.
22
+ // missing: ids that resolve to nothing come back as null instead of failing the query
23
+ export async function graphql(repo, query, variables, o = {}) {
24
+ const r = await gh(["api", "graphql", ...host(repo), "--input", "-"], JSON.stringify({ query, variables }));
25
+ let res = {};
26
+ try {
27
+ res = JSON.parse(r.stdout);
28
+ }
29
+ catch { }
30
+ if (o.missing && res.data && res.errors?.every((e) => e.type === "NOT_FOUND"))
31
+ return res.data;
32
+ if (r.code !== 0 || res.errors?.length || !res.data)
33
+ throw new Error(`GitHub said: ${res.errors?.map((e) => e.message).join("; ") || r.stderr.trim().slice(0, 300) || "nothing"}`);
34
+ return res.data;
35
+ }
36
+ const at = (repo, number) => ({ owner: repo.owner, name: repo.name, number });
37
+ // every review thread on the PR, keyed by its first comment's REST id
38
+ export async function reviewThreads(repo, number) {
39
+ const query = "query($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id isResolved comments(first: 1) { nodes { databaseId } } } } } } }";
40
+ const out = [];
41
+ let after;
42
+ // ponytail: stops at 1000 threads; no PR reviewed in a tour has that many
43
+ for (let page = 0; page < 10; page++) {
44
+ const t = (await graphql(repo, query, { ...at(repo, number), ...(after && { after }) })).repository.pullRequest.reviewThreads;
45
+ for (const n of t.nodes)
46
+ if (n.comments.nodes[0])
47
+ out.push({ id: n.id, root: n.comments.nodes[0].databaseId, resolved: n.isResolved });
48
+ if (!t.pageInfo.hasNextPage)
49
+ break;
50
+ after = t.pageInfo.endCursor;
51
+ }
52
+ return out;
53
+ }
54
+ const COMMENT = "id databaseId url";
55
+ const ref = (c) => ({ node: c.id, id: c.databaseId, ...(c.url && { url: c.url }) });
56
+ // the PR's node id and the reviewer's pending review on it, with its comments; GitHub shows each reviewer only their own
57
+ export async function pendingReview(repo, number) {
58
+ // ponytail: the first 100 pending comments; a review with more shows only those
59
+ const q = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { id reviews(states: PENDING, first: 1) { nodes { id url comments(first: 100) { nodes { ${COMMENT} body path line startLine replyTo { databaseId } createdAt } } } } } } }`;
60
+ const p = (await graphql(repo, q, at(repo, number))).repository.pullRequest;
61
+ const r = p.reviews.nodes[0];
62
+ return {
63
+ pr: p.id,
64
+ ...(r && {
65
+ review: {
66
+ id: r.id,
67
+ url: r.url,
68
+ // ponytail: a comment has no side, its thread does; one added on github.com is taken to be on the new file
69
+ comments: r.comments.nodes.map((c) => ({ ...ref(c), body: c.body, path: c.path, line: c.line, startLine: c.startLine, side: "RIGHT", startSide: c.startLine ? "RIGHT" : null, ...(c.replyTo && { replyTo: c.replyTo.databaseId }), at: c.createdAt })),
70
+ },
71
+ }),
72
+ };
73
+ }
74
+ export async function startPendingReview(repo, pr, head) {
75
+ return (await graphql(repo, "mutation($pullRequestId: ID!, $commitOID: GitObjectID) { addPullRequestReview(input: { pullRequestId: $pullRequestId, commitOID: $commitOID }) { pullRequestReview { id url } } }", { pullRequestId: pr, commitOID: head })).addPullRequestReview.pullRequestReview;
76
+ }
77
+ // a new thread in the pending review
78
+ export async function addReviewThread(repo, review, at, body) {
79
+ const t = (await graphql(repo, `mutation($reviewId: ID!, $path: String!, $body: String!, $line: Int!, $side: DiffSide, $startLine: Int, $startSide: DiffSide) { addPullRequestReviewThread(input: { pullRequestReviewId: $reviewId, path: $path, body: $body, line: $line, side: $side, startLine: $startLine, startSide: $startSide }) { thread { id comments(first: 1) { nodes { ${COMMENT} } } } } }`, { reviewId: review, body, ...at })).addPullRequestReviewThread.thread;
80
+ return { ...ref(t.comments.nodes[0]), thread: t.id };
81
+ }
82
+ // a reply in a thread, in the pending review
83
+ export async function addReviewReply(repo, review, thread, body) {
84
+ return ref((await graphql(repo, `mutation($reviewId: ID!, $threadId: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewId: $reviewId, pullRequestReviewThreadId: $threadId, body: $body }) { comment { ${COMMENT} } } }`, { reviewId: review, threadId: thread, body })).addPullRequestReviewThreadReply.comment);
85
+ }
86
+ // GitHub's "Add single comment": posted at once, outside any review (REST, which GraphQL has no mutation for)
87
+ export async function postSingle(repo, number, head, at, body) {
88
+ return rest(repo, `pulls/${number}/comments`, { body, commit_id: head, path: at.path, line: at.line, side: at.side, ...(at.startLine && { start_line: at.startLine, start_side: at.startSide }) });
89
+ }
90
+ export async function postSingleReply(repo, number, root, body) {
91
+ return rest(repo, `pulls/${number}/comments/${root}/replies`, { body });
92
+ }
93
+ async function rest(repo, path, input) {
94
+ const r = await gh(["api", ...host(repo), "-X", "POST", `repos/${repo.owner}/${repo.name}/${path}`, "--input", "-"], JSON.stringify(input));
95
+ let c = {};
96
+ try {
97
+ c = JSON.parse(r.stdout);
98
+ }
99
+ catch { }
100
+ if (r.code !== 0 || !c.id || !c.node_id)
101
+ throw new Error(`GitHub said: ${c.message ?? (r.stderr.trim().slice(0, 300) || "nothing")}`);
102
+ return { node: c.node_id, id: c.id, ...(c.html_url && { url: c.html_url }) };
103
+ }
104
+ export async function editGhComment(repo, node, body) {
105
+ await graphql(repo, "mutation($id: ID!, $body: String!) { updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $id, body: $body }) { pullRequestReviewComment { id } } }", { id: node, body });
106
+ }
107
+ export async function deleteGhComment(repo, node) {
108
+ await graphql(repo, "mutation($id: ID!) { deletePullRequestReviewComment(input: { id: $id }) { pullRequestReview { id } } }", { id: node });
109
+ }
110
+ // which of these comments GitHub still has, and whether they were submitted (null when deleted)
111
+ export async function commentStates(repo, nodes) {
112
+ if (!nodes.length)
113
+ return {};
114
+ const r = await graphql(repo, "query($ids: [ID!]!) { nodes(ids: $ids) { ... on PullRequestReviewComment { id state } } }", { ids: nodes }, { missing: true });
115
+ return Object.fromEntries(nodes.map((n, i) => [n, r.nodes[i]?.state ?? null]));
116
+ }
117
+ // submits the pending review with the verdict and summary, or posts a review of just those when there is none
118
+ export async function submitReview(repo, o) {
119
+ if (o.review) {
120
+ return (await graphql(repo, "mutation($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String) { submitPullRequestReview(input: { pullRequestReviewId: $reviewId, event: $event, body: $body }) { pullRequestReview { url } } }", { reviewId: o.review, event: o.event, body: o.body })).submitPullRequestReview.pullRequestReview.url;
121
+ }
122
+ return (await graphql(repo, "mutation($pullRequestId: ID!, $commitOID: GitObjectID, $event: PullRequestReviewEvent, $body: String) { addPullRequestReview(input: { pullRequestId: $pullRequestId, commitOID: $commitOID, event: $event, body: $body }) { pullRequestReview { url } } }", { pullRequestId: o.pr, commitOID: o.head, event: o.event, body: o.body })).addPullRequestReview.pullRequestReview.url;
123
+ }
124
+ const CHECKS = "statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion detailsUrl startedAt completedAt checkSuite { workflowRun { workflow { name } } } } ... on StatusContext { context state targetUrl createdAt } } } }";
125
+ export async function prStatus(repo, number) {
126
+ const q = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { viewerPermission mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerDefaultMergeMethod pullRequest(number: $number) { id number title url body state isDraft author { login } headRefName headRefOid baseRefName additions deletions changedFiles mergedAt mergedBy { login } mergeable mergeStateStatus reviewDecision reviews(last: 50) { nodes { author { login } state submittedAt } } reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } } commits(last: 1) { nodes { commit { ${CHECKS} } } } } } }`;
127
+ const r = (await graphql(repo, q, at(repo, number))).repository;
128
+ const p = r.pullRequest;
129
+ const run = (c) => {
130
+ if (c.__typename === "StatusContext") {
131
+ const s = c.state === "SUCCESS" ? "pass" : c.state === "PENDING" || c.state === "EXPECTED" ? "pending" : "fail";
132
+ return { name: c.context ?? "status", status: s, ...(c.targetUrl && { url: c.targetUrl }), ...(c.createdAt && { started: c.createdAt }) };
133
+ }
134
+ const s = c.status !== "COMPLETED" ? "pending" : c.conclusion === "SUCCESS" ? "pass" : c.conclusion === "SKIPPED" ? "skipped" : c.conclusion === "NEUTRAL" ? "neutral" : "fail";
135
+ const workflow = c.checkSuite?.workflowRun?.workflow?.name;
136
+ return { name: c.name ?? "check", status: s, ...(workflow && { workflow }), ...(c.detailsUrl && { url: c.detailsUrl }), ...(c.startedAt && { started: c.startedAt }), ...(c.completedAt && { finished: c.completedAt }) };
137
+ };
138
+ // the latest review of each reviewer counts, as on GitHub; comments alone do not change it
139
+ const latest = new Map();
140
+ for (const v of p.reviews.nodes)
141
+ if (v.author && v.state !== "PENDING" && (v.state !== "COMMENTED" || !latest.has(v.author.login)))
142
+ latest.set(v.author.login, { by: v.author.login, state: v.state, ...(v.submittedAt && { at: v.submittedAt }) });
143
+ const methods = [["SQUASH", r.squashMergeAllowed], ["MERGE", r.mergeCommitAllowed], ["REBASE", r.rebaseMergeAllowed]].filter(([, ok]) => ok).map(([m]) => m);
144
+ return {
145
+ id: p.id, number: p.number, title: p.title, url: p.url, body: p.body ?? "", state: p.state, draft: p.isDraft, ...(p.author && { author: p.author.login }),
146
+ head: { ref: p.headRefName, oid: p.headRefOid }, base: p.baseRefName, additions: p.additions, deletions: p.deletions, files: p.changedFiles,
147
+ ...(p.state === "MERGED" && { merged: { ...(p.mergedAt && { at: p.mergedAt }), ...(p.mergedBy && { by: p.mergedBy.login }) } }),
148
+ mergeable: p.mergeable, mergeState: p.mergeStateStatus, ...(p.reviewDecision && { reviewDecision: p.reviewDecision }),
149
+ reviews: [...latest.values()],
150
+ requested: p.reviewRequests.nodes.map((n) => n.requestedReviewer?.login ?? n.requestedReviewer?.name).filter((x) => !!x),
151
+ checks: (p.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []).map(run),
152
+ canMerge: ["ADMIN", "MAINTAIN", "WRITE"].includes(r.viewerPermission ?? ""),
153
+ methods,
154
+ ...(r.viewerDefaultMergeMethod && methods.includes(r.viewerDefaultMergeMethod) && { defaultMethod: r.viewerDefaultMergeMethod }),
155
+ };
156
+ }
157
+ // merges the PR, only if its head is still the commit the reviewer saw
158
+ export async function mergePr(repo, o) {
159
+ await graphql(repo, "mutation($id: ID!, $method: PullRequestMergeMethod!, $head: GitObjectID!) { mergePullRequest(input: { pullRequestId: $id, mergeMethod: $method, expectedHeadOid: $head }) { pullRequest { merged } } }", { id: o.id, method: o.method, head: o.head });
160
+ }
@@ -0,0 +1,14 @@
1
+ export interface LocalChanges {
2
+ raw: Record<string, unknown>;
3
+ diff: string;
4
+ base: string;
5
+ branch: string;
6
+ head: Buffer;
7
+ baseTree: Buffer;
8
+ }
9
+ export declare function localChanges(root: string, opts?: {
10
+ base?: string;
11
+ note?: string;
12
+ repo?: string;
13
+ exclude?: string[];
14
+ }): Promise<LocalChanges>;
@@ -0,0 +1,70 @@
1
+ // The local changes of a checkout, as a review sees them: head is the working tree (commits, uncommitted and untracked
2
+ // files), base is where the branch left the default branch. Read without touching the checkout or its index.
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { BsError } from "../config.js";
8
+ import { run } from "../util.js";
9
+ import { workingTree } from "../repo.js";
10
+ async function git(root, args, env = {}) {
11
+ const r = await run("git", args, { cwd: root, check: false, env });
12
+ if (r.code !== 0)
13
+ throw new BsError("git_failed", `git ${args.join(" ")} failed: ${r.stderr.trim().slice(0, 300)}`, "run it in the repo yourself to see why");
14
+ return r.stdout;
15
+ }
16
+ // the default branch: origin's HEAD, else main or master
17
+ async function defaultBranch(root) {
18
+ const head = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root, check: false });
19
+ if (head.code === 0 && head.stdout.trim())
20
+ return head.stdout.trim();
21
+ for (const b of ["origin/main", "origin/master", "main", "master"])
22
+ if ((await run("git", ["rev-parse", "--verify", "--quiet", b], { cwd: root, check: false })).code === 0)
23
+ return b;
24
+ throw new BsError("no_base", "could not tell which branch these changes are based on", "pass --base <branch or commit>");
25
+ }
26
+ const tarOf = (root, ref) => new Promise((resolve, reject) => {
27
+ const p = spawn("git", ["archive", "--format=tar", ref], { cwd: root });
28
+ const chunks = [];
29
+ let err = "";
30
+ p.stdout.on("data", (d) => chunks.push(d));
31
+ p.stderr.on("data", (d) => (err += d));
32
+ p.on("error", reject);
33
+ p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new BsError("git_failed", `git archive ${ref} failed: ${err.trim()}`, "check that the base exists"))));
34
+ });
35
+ // exclude: files that never belong in the diff, e.g. the .env files, which hold secrets and reach the box on their own
36
+ export async function localChanges(root, opts = {}) {
37
+ const against = opts.base ?? (await defaultBranch(root));
38
+ const base = (await git(root, ["merge-base", "HEAD", against])).trim();
39
+ const branch = (await run("git", ["symbolic-ref", "--short", "HEAD"], { cwd: root, check: false })).stdout.trim() || "local changes";
40
+ // the working tree as one snapshot, staged in a throwaway index so the user's stays as it is
41
+ const tmp = mkdtempSync(join(tmpdir(), "bs-index-"));
42
+ const env = { GIT_INDEX_FILE: join(tmp, "index") };
43
+ let diff, numstat;
44
+ try {
45
+ await git(root, ["read-tree", "HEAD"], env);
46
+ await git(root, ["add", "-A"], env);
47
+ if (opts.exclude?.length)
48
+ await git(root, ["rm", "--cached", "-q", "--ignore-unmatch", "--", ...opts.exclude], env);
49
+ diff = await git(root, ["diff", "--cached", "--no-color", base], env);
50
+ numstat = await git(root, ["diff", "--cached", "--numstat", base], env);
51
+ }
52
+ finally {
53
+ rmSync(tmp, { recursive: true, force: true });
54
+ }
55
+ if (!diff.trim())
56
+ throw new BsError("no_changes", `nothing changed since ${against} (${base.slice(0, 7)})`, "make changes first, or pass --base <ref> to compare with something else");
57
+ const files = numstat.trim().split("\n").map((l) => {
58
+ const [a, d, path] = l.split("\t");
59
+ return { path, additions: Number(a) || 0, deletions: Number(d) || 0 };
60
+ });
61
+ const author = (await run("git", ["config", "user.name"], { cwd: root, check: false })).stdout.trim() || "you";
62
+ return {
63
+ raw: { number: 0, title: branch, url: "", repo: opts.repo ?? root.split("/").pop(), body: opts.note ?? "", author: { login: author }, baseRefName: against.replace(/^origin\//, ""), headRefName: branch, files, comments: [], reviews: [] },
64
+ diff,
65
+ base,
66
+ branch,
67
+ head: await workingTree(root),
68
+ baseTree: await tarOf(root, base),
69
+ };
70
+ }
@@ -0,0 +1,12 @@
1
+ import { type Box } from "../boxes.ts";
2
+ import type { Waited } from "./comments.ts";
3
+ export declare function waitForComments(b: Box, seconds?: number, retry?: number): Promise<Waited>;
4
+ export type Hooks = "kept" | "conflict" | "none";
5
+ export declare function updateReview(b: Box, root: string, o: {
6
+ note: string;
7
+ answers: Record<string, string>;
8
+ }): Promise<{
9
+ update: number;
10
+ hooks: Hooks;
11
+ }>;
12
+ export declare function refreshPrompt(n: number, note: string, hooks: Hooks): string;