@hellcoder/companion 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,498 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const mockExecSync = vi.hoisted(() => vi.fn());
8
+ const mockExecFileSync = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("node:child_process", () => ({
11
+ execSync: mockExecSync,
12
+ execFileSync: mockExecFileSync,
13
+ }));
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Module under test — re-imported to reset module-level caches
17
+ // ---------------------------------------------------------------------------
18
+ let mod: typeof import("./github-pr.js");
19
+
20
+ beforeEach(async () => {
21
+ vi.resetModules();
22
+ mockExecSync.mockReset();
23
+ mockExecFileSync.mockReset();
24
+ mod = await import("./github-pr.js");
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Sample GraphQL response (matches real GitHub API shape)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function makeGraphQLResponse(prOverrides?: Record<string, unknown>) {
32
+ return {
33
+ data: {
34
+ repository: {
35
+ pullRequests: {
36
+ nodes: prOverrides === null ? [] : [{
37
+ number: 162,
38
+ title: "feat: add dark mode toggle",
39
+ url: "https://github.com/org/repo/pull/162",
40
+ state: "OPEN",
41
+ isDraft: false,
42
+ isCrossRepository: false,
43
+ reviewDecision: "CHANGES_REQUESTED",
44
+ additions: 91,
45
+ deletions: 88,
46
+ changedFiles: 24,
47
+ reviewThreads: {
48
+ totalCount: 4,
49
+ nodes: [
50
+ { isResolved: true },
51
+ { isResolved: true },
52
+ { isResolved: false },
53
+ { isResolved: false },
54
+ ],
55
+ },
56
+ commits: {
57
+ nodes: [{
58
+ commit: {
59
+ statusCheckRollup: {
60
+ contexts: {
61
+ nodes: [
62
+ { __typename: "CheckRun", name: "CI / Build", status: "COMPLETED", conclusion: "SUCCESS" },
63
+ { __typename: "CheckRun", name: "CI / Test", status: "COMPLETED", conclusion: "FAILURE" },
64
+ { __typename: "StatusContext", context: "deploy/preview", state: "SUCCESS" },
65
+ ],
66
+ },
67
+ },
68
+ },
69
+ }],
70
+ },
71
+ ...prOverrides,
72
+ }],
73
+ },
74
+ },
75
+ },
76
+ };
77
+ }
78
+
79
+ // ===========================================================================
80
+ // isGhAvailable
81
+ // ===========================================================================
82
+ describe("isGhAvailable", () => {
83
+ it("returns true when `which gh` succeeds", () => {
84
+ mockExecSync.mockReturnValue("/opt/homebrew/bin/gh");
85
+ expect(mod.isGhAvailable()).toBe(true);
86
+ });
87
+
88
+ it("returns false when `which gh` throws", () => {
89
+ mockExecSync.mockImplementation(() => {
90
+ throw new Error("not found");
91
+ });
92
+ expect(mod.isGhAvailable()).toBe(false);
93
+ });
94
+
95
+ it("caches the result across calls", () => {
96
+ mockExecSync.mockReturnValue("/opt/homebrew/bin/gh");
97
+ mod.isGhAvailable();
98
+ mod.isGhAvailable();
99
+ // Only the first call for `which gh`; subsequent calls should be cached
100
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
101
+ });
102
+ });
103
+
104
+ // ===========================================================================
105
+ // parseGraphQLResponse
106
+ // ===========================================================================
107
+ describe("parseGraphQLResponse", () => {
108
+ it("parses a full response with CheckRun and StatusContext nodes", () => {
109
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse());
110
+ expect(result).not.toBeNull();
111
+ expect(result!.number).toBe(162);
112
+ expect(result!.title).toBe("feat: add dark mode toggle");
113
+ expect(result!.state).toBe("OPEN");
114
+ expect(result!.isDraft).toBe(false);
115
+ expect(result!.reviewDecision).toBe("CHANGES_REQUESTED");
116
+ expect(result!.additions).toBe(91);
117
+ expect(result!.deletions).toBe(88);
118
+ expect(result!.changedFiles).toBe(24);
119
+ });
120
+
121
+ it("computes checksSummary correctly", () => {
122
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse())!;
123
+ expect(result.checksSummary).toEqual({
124
+ total: 3,
125
+ success: 2, // CI/Build SUCCESS + deploy/preview SUCCESS
126
+ failure: 1, // CI/Test FAILURE
127
+ pending: 0,
128
+ });
129
+ });
130
+
131
+ it("normalizes StatusContext into check format", () => {
132
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse())!;
133
+ const deployCheck = result.checks.find((c) => c.name === "deploy/preview");
134
+ expect(deployCheck).toEqual({
135
+ name: "deploy/preview",
136
+ status: "COMPLETED",
137
+ conclusion: "SUCCESS",
138
+ });
139
+ });
140
+
141
+ it("computes reviewThreads correctly", () => {
142
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse())!;
143
+ expect(result.reviewThreads).toEqual({
144
+ total: 4,
145
+ resolved: 2,
146
+ unresolved: 2,
147
+ });
148
+ });
149
+
150
+ it("returns null for empty PR nodes", () => {
151
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse(null as any));
152
+ expect(result).toBeNull();
153
+ });
154
+
155
+ it("returns null for malformed input", () => {
156
+ expect(mod.parseGraphQLResponse(null)).toBeNull();
157
+ expect(mod.parseGraphQLResponse(undefined)).toBeNull();
158
+ expect(mod.parseGraphQLResponse("not json")).toBeNull();
159
+ expect(mod.parseGraphQLResponse({ data: null })).toBeNull();
160
+ });
161
+
162
+ it("handles PR with no checks (statusCheckRollup null)", () => {
163
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
164
+ commits: { nodes: [{ commit: { statusCheckRollup: null } }] },
165
+ }))!;
166
+ expect(result.checks).toEqual([]);
167
+ expect(result.checksSummary).toEqual({ total: 0, success: 0, failure: 0, pending: 0 });
168
+ });
169
+
170
+ it("handles PR with no review threads", () => {
171
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
172
+ reviewThreads: { totalCount: 0, nodes: [] },
173
+ }))!;
174
+ expect(result.reviewThreads).toEqual({ total: 0, resolved: 0, unresolved: 0 });
175
+ });
176
+
177
+ it("handles pending StatusContext (PENDING state)", () => {
178
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
179
+ commits: {
180
+ nodes: [{
181
+ commit: {
182
+ statusCheckRollup: {
183
+ contexts: {
184
+ nodes: [
185
+ { __typename: "StatusContext", context: "ci/deploy", state: "PENDING" },
186
+ ],
187
+ },
188
+ },
189
+ },
190
+ }],
191
+ },
192
+ }))!;
193
+ expect(result.checks[0]).toEqual({
194
+ name: "ci/deploy",
195
+ status: "IN_PROGRESS",
196
+ conclusion: null,
197
+ });
198
+ expect(result.checksSummary.pending).toBe(1);
199
+ });
200
+
201
+ it("counts NEUTRAL and SKIPPED as success", () => {
202
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
203
+ commits: {
204
+ nodes: [{
205
+ commit: {
206
+ statusCheckRollup: {
207
+ contexts: {
208
+ nodes: [
209
+ { __typename: "CheckRun", name: "optional", status: "COMPLETED", conclusion: "NEUTRAL" },
210
+ { __typename: "CheckRun", name: "skipped", status: "COMPLETED", conclusion: "SKIPPED" },
211
+ ],
212
+ },
213
+ },
214
+ },
215
+ }],
216
+ },
217
+ }))!;
218
+ expect(result.checksSummary.success).toBe(2);
219
+ expect(result.checksSummary.failure).toBe(0);
220
+ });
221
+
222
+ it("treats StatusContext ERROR state as failure", () => {
223
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
224
+ commits: {
225
+ nodes: [{
226
+ commit: {
227
+ statusCheckRollup: {
228
+ contexts: {
229
+ nodes: [
230
+ { __typename: "StatusContext", context: "ci/build", state: "ERROR" },
231
+ ],
232
+ },
233
+ },
234
+ },
235
+ }],
236
+ },
237
+ }))!;
238
+ expect(result.checks[0]).toEqual({
239
+ name: "ci/build",
240
+ status: "COMPLETED",
241
+ conclusion: "FAILURE",
242
+ });
243
+ expect(result.checksSummary.failure).toBe(1);
244
+ expect(result.checksSummary.pending).toBe(0);
245
+ });
246
+
247
+ it("counts CANCELLED and TIMED_OUT as failure", () => {
248
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
249
+ commits: {
250
+ nodes: [{
251
+ commit: {
252
+ statusCheckRollup: {
253
+ contexts: {
254
+ nodes: [
255
+ { __typename: "CheckRun", name: "cancelled", status: "COMPLETED", conclusion: "CANCELLED" },
256
+ { __typename: "CheckRun", name: "timeout", status: "COMPLETED", conclusion: "TIMED_OUT" },
257
+ ],
258
+ },
259
+ },
260
+ },
261
+ }],
262
+ },
263
+ }))!;
264
+ expect(result.checksSummary.failure).toBe(2);
265
+ });
266
+
267
+ it("handles isDraft=true", () => {
268
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({ isDraft: true }))!;
269
+ expect(result.isDraft).toBe(true);
270
+ });
271
+
272
+ it("defaults isDraft to false when missing", () => {
273
+ const response = makeGraphQLResponse();
274
+ delete (response.data.repository.pullRequests.nodes[0] as any).isDraft;
275
+ const result = mod.parseGraphQLResponse(response)!;
276
+ expect(result.isDraft).toBe(false);
277
+ });
278
+
279
+ it("normalizes null reviewDecision", () => {
280
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({ reviewDecision: null }))!;
281
+ expect(result.reviewDecision).toBeNull();
282
+ });
283
+
284
+ it("normalizes empty string reviewDecision to null", () => {
285
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({ reviewDecision: "" }))!;
286
+ expect(result.reviewDecision).toBeNull();
287
+ });
288
+
289
+ it("parses a merged PR correctly", () => {
290
+ const result = mod.parseGraphQLResponse(makeGraphQLResponse({
291
+ state: "MERGED",
292
+ reviewDecision: "APPROVED",
293
+ }))!;
294
+ expect(result).not.toBeNull();
295
+ expect(result.state).toBe("MERGED");
296
+ expect(result.reviewDecision).toBe("APPROVED");
297
+ });
298
+
299
+ it("filters out cross-repository (fork) PRs", () => {
300
+ const response = {
301
+ data: {
302
+ repository: {
303
+ pullRequests: {
304
+ nodes: [
305
+ { ...makeGraphQLResponse().data.repository.pullRequests.nodes[0], isCrossRepository: true, number: 100 },
306
+ { ...makeGraphQLResponse().data.repository.pullRequests.nodes[0], isCrossRepository: false, number: 200 },
307
+ ],
308
+ },
309
+ },
310
+ },
311
+ };
312
+ const result = mod.parseGraphQLResponse(response)!;
313
+ expect(result).not.toBeNull();
314
+ expect(result.number).toBe(200);
315
+ });
316
+
317
+ it("returns null when all PRs are cross-repository", () => {
318
+ const response = {
319
+ data: {
320
+ repository: {
321
+ pullRequests: {
322
+ nodes: [
323
+ { ...makeGraphQLResponse().data.repository.pullRequests.nodes[0], isCrossRepository: true },
324
+ ],
325
+ },
326
+ },
327
+ },
328
+ };
329
+ const result = mod.parseGraphQLResponse(response);
330
+ expect(result).toBeNull();
331
+ });
332
+ });
333
+
334
+ // ===========================================================================
335
+ // fetchPRInfo
336
+ // ===========================================================================
337
+ describe("fetchPRInfo", () => {
338
+ it("returns null when gh is not available", async () => {
339
+ // First call: `which gh` throws
340
+ mockExecSync.mockImplementation(() => {
341
+ throw new Error("not found");
342
+ });
343
+ const result = await mod.fetchPRInfo("/some/path", "main");
344
+ expect(result).toBeNull();
345
+ });
346
+
347
+ it("returns parsed PR info on success", async () => {
348
+ // which gh + gh repo view use execSync; gh api graphql uses execFileSync
349
+ mockExecSync
350
+ .mockReturnValueOnce("/opt/homebrew/bin/gh") // which gh
351
+ .mockReturnValueOnce("The-Vibe-Company/companion"); // gh repo view
352
+ mockExecFileSync
353
+ .mockReturnValueOnce(JSON.stringify(makeGraphQLResponse())); // gh api graphql
354
+
355
+ const result = await mod.fetchPRInfo("/project", "feat/dark-mode");
356
+ expect(result).not.toBeNull();
357
+ expect(result!.number).toBe(162);
358
+ expect(result!.state).toBe("OPEN");
359
+ });
360
+
361
+ it("returns null when repo slug cannot be resolved", async () => {
362
+ mockExecSync
363
+ .mockReturnValueOnce("/opt/homebrew/bin/gh") // which gh
364
+ .mockImplementationOnce(() => { throw new Error("not a gh repo"); }); // gh repo view
365
+
366
+ const result = await mod.fetchPRInfo("/not-a-repo", "main");
367
+ expect(result).toBeNull();
368
+ });
369
+
370
+ it("returns null when graphql query fails", async () => {
371
+ mockExecSync
372
+ .mockReturnValueOnce("/opt/homebrew/bin/gh") // which gh
373
+ .mockReturnValueOnce("owner/repo"); // gh repo view
374
+ mockExecFileSync
375
+ .mockImplementationOnce(() => { throw new Error("timeout"); }); // gh api graphql
376
+
377
+ const result = await mod.fetchPRInfo("/project", "main");
378
+ expect(result).toBeNull();
379
+ });
380
+
381
+ it("returns null when no PR exists for branch", async () => {
382
+ const emptyResponse = { data: { repository: { pullRequests: { nodes: [] } } } };
383
+ mockExecSync
384
+ .mockReturnValueOnce("/opt/homebrew/bin/gh")
385
+ .mockReturnValueOnce("owner/repo");
386
+ mockExecFileSync
387
+ .mockReturnValueOnce(JSON.stringify(emptyResponse));
388
+
389
+ const result = await mod.fetchPRInfo("/project", "no-pr-branch");
390
+ expect(result).toBeNull();
391
+ });
392
+
393
+ it("caches results within TTL", async () => {
394
+ mockExecSync
395
+ .mockReturnValueOnce("/opt/homebrew/bin/gh")
396
+ .mockReturnValueOnce("owner/repo");
397
+ mockExecFileSync
398
+ .mockReturnValueOnce(JSON.stringify(makeGraphQLResponse()));
399
+
400
+ const first = await mod.fetchPRInfo("/project", "feat/cached");
401
+ const second = await mod.fetchPRInfo("/project", "feat/cached");
402
+
403
+ expect(first).toEqual(second);
404
+ // which gh (1) + repo view (1) = 2 execSync calls, graphql (1) = 1 execFileSync call
405
+ expect(mockExecSync).toHaveBeenCalledTimes(2);
406
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
407
+ });
408
+
409
+ it("returns null for malformed JSON response", async () => {
410
+ mockExecSync
411
+ .mockReturnValueOnce("/opt/homebrew/bin/gh")
412
+ .mockReturnValueOnce("owner/repo");
413
+ mockExecFileSync
414
+ .mockReturnValueOnce("NOT VALID JSON{{{");
415
+
416
+ const result = await mod.fetchPRInfo("/project", "main");
417
+ expect(result).toBeNull();
418
+ });
419
+ });
420
+
421
+ // ===========================================================================
422
+ // computeAdaptiveTTL
423
+ // ===========================================================================
424
+ describe("computeAdaptiveTTL", () => {
425
+ function makePR(overrides?: Partial<import("./github-pr.js").GitHubPRInfo>): import("./github-pr.js").GitHubPRInfo {
426
+ return {
427
+ number: 1,
428
+ title: "test",
429
+ url: "https://github.com/o/r/pull/1",
430
+ state: "OPEN",
431
+ isDraft: false,
432
+ reviewDecision: null,
433
+ additions: 0,
434
+ deletions: 0,
435
+ changedFiles: 0,
436
+ checks: [],
437
+ checksSummary: { total: 0, success: 0, failure: 0, pending: 0 },
438
+ reviewThreads: { total: 0, resolved: 0, unresolved: 0 },
439
+ ...overrides,
440
+ };
441
+ }
442
+
443
+ it("returns 60s for null (no PR)", () => {
444
+ expect(mod.computeAdaptiveTTL(null)).toBe(60_000);
445
+ });
446
+
447
+ it("returns 300s (5 min) for merged PR", () => {
448
+ expect(mod.computeAdaptiveTTL(makePR({ state: "MERGED" }))).toBe(300_000);
449
+ });
450
+
451
+ it("returns 300s (5 min) for closed PR", () => {
452
+ expect(mod.computeAdaptiveTTL(makePR({ state: "CLOSED" }))).toBe(300_000);
453
+ });
454
+
455
+ it("returns 10s for CI pending", () => {
456
+ expect(mod.computeAdaptiveTTL(makePR({
457
+ checksSummary: { total: 3, success: 1, failure: 0, pending: 2 },
458
+ }))).toBe(10_000);
459
+ });
460
+
461
+ it("returns 30s for CI failed", () => {
462
+ expect(mod.computeAdaptiveTTL(makePR({
463
+ checksSummary: { total: 3, success: 2, failure: 1, pending: 0 },
464
+ }))).toBe(30_000);
465
+ });
466
+
467
+ it("returns 30s for changes requested", () => {
468
+ expect(mod.computeAdaptiveTTL(makePR({
469
+ reviewDecision: "CHANGES_REQUESTED",
470
+ }))).toBe(30_000);
471
+ });
472
+
473
+ it("returns 120s for approved with no pending checks", () => {
474
+ expect(mod.computeAdaptiveTTL(makePR({
475
+ reviewDecision: "APPROVED",
476
+ checksSummary: { total: 2, success: 2, failure: 0, pending: 0 },
477
+ }))).toBe(120_000);
478
+ });
479
+
480
+ it("returns 45s for review required", () => {
481
+ expect(mod.computeAdaptiveTTL(makePR({
482
+ reviewDecision: "REVIEW_REQUIRED",
483
+ }))).toBe(45_000);
484
+ });
485
+
486
+ it("returns 45s for null reviewDecision (open PR)", () => {
487
+ expect(mod.computeAdaptiveTTL(makePR({
488
+ reviewDecision: null,
489
+ }))).toBe(45_000);
490
+ });
491
+
492
+ it("pending checks take priority over review state", () => {
493
+ expect(mod.computeAdaptiveTTL(makePR({
494
+ reviewDecision: "CHANGES_REQUESTED",
495
+ checksSummary: { total: 3, success: 1, failure: 0, pending: 2 },
496
+ }))).toBe(10_000);
497
+ });
498
+ });