@aaronshaf/ger 1.2.11 → 2.0.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 (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -196
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,789 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { Effect } from 'effect'
4
+ import { buildStatusCommand } from '@/cli/commands/build-status'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import type { MessageInfo } from '@/schemas/gerrit'
7
+ import {
8
+ server,
9
+ capturedStdout,
10
+ capturedErrors,
11
+ mockProcessExit,
12
+ setupBuildStatusTests,
13
+ teardownBuildStatusTests,
14
+ resetBuildStatusMocks,
15
+ createMockConfigLayer,
16
+ } from './helpers/build-status-test-setup'
17
+
18
+ beforeAll(() => {
19
+ setupBuildStatusTests()
20
+ })
21
+
22
+ afterAll(() => {
23
+ teardownBuildStatusTests()
24
+ })
25
+
26
+ afterEach(() => {
27
+ resetBuildStatusMocks()
28
+ })
29
+
30
+ describe('build-status command', () => {
31
+ test('returns pending when no Build Started message found', async () => {
32
+ const messages: MessageInfo[] = [
33
+ {
34
+ id: 'msg1',
35
+ message: 'Patch Set 1',
36
+ date: '2024-01-15 10:00:00.000000000',
37
+ author: {
38
+ _account_id: 1001,
39
+ name: 'Test User',
40
+ },
41
+ },
42
+ {
43
+ id: 'msg2',
44
+ message: 'Review comment',
45
+ date: '2024-01-15 10:30:00.000000000',
46
+ author: {
47
+ _account_id: 1002,
48
+ name: 'Reviewer',
49
+ },
50
+ },
51
+ ]
52
+
53
+ server.use(
54
+ http.get('*/a/changes/12345', ({ request }) => {
55
+ const url = new URL(request.url)
56
+ if (url.searchParams.get('o') === 'MESSAGES') {
57
+ return HttpResponse.json(
58
+ { messages },
59
+ {
60
+ headers: { 'Content-Type': 'application/json' },
61
+ },
62
+ )
63
+ }
64
+ return HttpResponse.text('Not Found', { status: 404 })
65
+ }),
66
+ )
67
+
68
+ const effect = buildStatusCommand('12345').pipe(
69
+ Effect.provide(GerritApiServiceLive),
70
+ Effect.provide(createMockConfigLayer()),
71
+ )
72
+
73
+ await Effect.runPromise(effect)
74
+
75
+ expect(capturedStdout.length).toBe(1)
76
+ const output = JSON.parse(capturedStdout[0])
77
+ expect(output).toEqual({ state: 'pending' })
78
+ })
79
+
80
+ test('returns running when Build Started but no verification', async () => {
81
+ const messages: MessageInfo[] = [
82
+ {
83
+ id: 'msg1',
84
+ message: 'Patch Set 1',
85
+ date: '2024-01-15 10:00:00.000000000',
86
+ author: {
87
+ _account_id: 1001,
88
+ name: 'Test User',
89
+ },
90
+ },
91
+ {
92
+ id: 'msg2',
93
+ message: 'Build Started',
94
+ date: '2024-01-15 10:05:00.000000000',
95
+ author: {
96
+ _account_id: 9999,
97
+ name: 'CI Bot',
98
+ },
99
+ },
100
+ {
101
+ id: 'msg3',
102
+ message: 'Some other message',
103
+ date: '2024-01-15 10:10:00.000000000',
104
+ author: {
105
+ _account_id: 1002,
106
+ name: 'Reviewer',
107
+ },
108
+ },
109
+ ]
110
+
111
+ server.use(
112
+ http.get('*/a/changes/12345', ({ request }) => {
113
+ const url = new URL(request.url)
114
+ if (url.searchParams.get('o') === 'MESSAGES') {
115
+ return HttpResponse.json(
116
+ { messages },
117
+ {
118
+ headers: { 'Content-Type': 'application/json' },
119
+ },
120
+ )
121
+ }
122
+ return HttpResponse.text('Not Found', { status: 404 })
123
+ }),
124
+ )
125
+
126
+ const effect = buildStatusCommand('12345').pipe(
127
+ Effect.provide(GerritApiServiceLive),
128
+ Effect.provide(createMockConfigLayer()),
129
+ )
130
+
131
+ await Effect.runPromise(effect)
132
+
133
+ expect(capturedStdout.length).toBe(1)
134
+ const output = JSON.parse(capturedStdout[0])
135
+ expect(output).toEqual({ state: 'running' })
136
+ })
137
+
138
+ test('returns success when Verified+1 after Build Started', async () => {
139
+ const messages: MessageInfo[] = [
140
+ {
141
+ id: 'msg1',
142
+ message: 'Patch Set 1',
143
+ date: '2024-01-15 10:00:00.000000000',
144
+ author: {
145
+ _account_id: 1001,
146
+ name: 'Test User',
147
+ },
148
+ },
149
+ {
150
+ id: 'msg2',
151
+ message: 'Build Started',
152
+ date: '2024-01-15 10:05:00.000000000',
153
+ author: {
154
+ _account_id: 9999,
155
+ name: 'CI Bot',
156
+ },
157
+ },
158
+ {
159
+ id: 'msg3',
160
+ message: 'Patch Set 1: Verified+1',
161
+ date: '2024-01-15 10:15:00.000000000',
162
+ author: {
163
+ _account_id: 9999,
164
+ name: 'CI Bot',
165
+ },
166
+ },
167
+ ]
168
+
169
+ server.use(
170
+ http.get('*/a/changes/12345', ({ request }) => {
171
+ const url = new URL(request.url)
172
+ if (url.searchParams.get('o') === 'MESSAGES') {
173
+ return HttpResponse.json(
174
+ { messages },
175
+ {
176
+ headers: { 'Content-Type': 'application/json' },
177
+ },
178
+ )
179
+ }
180
+ return HttpResponse.text('Not Found', { status: 404 })
181
+ }),
182
+ )
183
+
184
+ const effect = buildStatusCommand('12345').pipe(
185
+ Effect.provide(GerritApiServiceLive),
186
+ Effect.provide(createMockConfigLayer()),
187
+ )
188
+
189
+ await Effect.runPromise(effect)
190
+
191
+ expect(capturedStdout.length).toBe(1)
192
+ const output = JSON.parse(capturedStdout[0])
193
+ expect(output).toEqual({ state: 'success' })
194
+ })
195
+
196
+ test('returns failure when Verified-1 after Build Started', async () => {
197
+ const messages: MessageInfo[] = [
198
+ {
199
+ id: 'msg1',
200
+ message: 'Patch Set 1',
201
+ date: '2024-01-15 10:00:00.000000000',
202
+ author: {
203
+ _account_id: 1001,
204
+ name: 'Test User',
205
+ },
206
+ },
207
+ {
208
+ id: 'msg2',
209
+ message: 'Build Started',
210
+ date: '2024-01-15 10:05:00.000000000',
211
+ author: {
212
+ _account_id: 9999,
213
+ name: 'CI Bot',
214
+ },
215
+ },
216
+ {
217
+ id: 'msg3',
218
+ message: 'Patch Set 1: Verified-1\n\nBuild Failed',
219
+ date: '2024-01-15 10:20:00.000000000',
220
+ author: {
221
+ _account_id: 9999,
222
+ name: 'CI Bot',
223
+ },
224
+ },
225
+ ]
226
+
227
+ server.use(
228
+ http.get('*/a/changes/12345', ({ request }) => {
229
+ const url = new URL(request.url)
230
+ if (url.searchParams.get('o') === 'MESSAGES') {
231
+ return HttpResponse.json(
232
+ { messages },
233
+ {
234
+ headers: { 'Content-Type': 'application/json' },
235
+ },
236
+ )
237
+ }
238
+ return HttpResponse.text('Not Found', { status: 404 })
239
+ }),
240
+ )
241
+
242
+ const effect = buildStatusCommand('12345').pipe(
243
+ Effect.provide(GerritApiServiceLive),
244
+ Effect.provide(createMockConfigLayer()),
245
+ )
246
+
247
+ await Effect.runPromise(effect)
248
+
249
+ expect(capturedStdout.length).toBe(1)
250
+ const output = JSON.parse(capturedStdout[0])
251
+ expect(output).toEqual({ state: 'failure' })
252
+ })
253
+
254
+ test('ignores Verified messages before Build Started', async () => {
255
+ const messages: MessageInfo[] = [
256
+ {
257
+ id: 'msg1',
258
+ message: 'Patch Set 1: Verified+1',
259
+ date: '2024-01-15 09:00:00.000000000',
260
+ author: {
261
+ _account_id: 9999,
262
+ name: 'CI Bot',
263
+ },
264
+ },
265
+ {
266
+ id: 'msg2',
267
+ message: 'Build Started',
268
+ date: '2024-01-15 10:00:00.000000000',
269
+ author: {
270
+ _account_id: 9999,
271
+ name: 'CI Bot',
272
+ },
273
+ },
274
+ ]
275
+
276
+ server.use(
277
+ http.get('*/a/changes/12345', ({ request }) => {
278
+ const url = new URL(request.url)
279
+ if (url.searchParams.get('o') === 'MESSAGES') {
280
+ return HttpResponse.json(
281
+ { messages },
282
+ {
283
+ headers: { 'Content-Type': 'application/json' },
284
+ },
285
+ )
286
+ }
287
+ return HttpResponse.text('Not Found', { status: 404 })
288
+ }),
289
+ )
290
+
291
+ const effect = buildStatusCommand('12345').pipe(
292
+ Effect.provide(GerritApiServiceLive),
293
+ Effect.provide(createMockConfigLayer()),
294
+ )
295
+
296
+ await Effect.runPromise(effect)
297
+
298
+ expect(capturedStdout.length).toBe(1)
299
+ const output = JSON.parse(capturedStdout[0])
300
+ expect(output).toEqual({ state: 'running' })
301
+ })
302
+
303
+ test('uses most recent Build Started message', async () => {
304
+ const messages: MessageInfo[] = [
305
+ {
306
+ id: 'msg1',
307
+ message: 'Build Started',
308
+ date: '2024-01-15 09:00:00.000000000',
309
+ author: {
310
+ _account_id: 9999,
311
+ name: 'CI Bot',
312
+ },
313
+ },
314
+ {
315
+ id: 'msg2',
316
+ message: 'Patch Set 1: Verified-1',
317
+ date: '2024-01-15 09:30:00.000000000',
318
+ author: {
319
+ _account_id: 9999,
320
+ name: 'CI Bot',
321
+ },
322
+ },
323
+ {
324
+ id: 'msg3',
325
+ message: 'Build Started',
326
+ date: '2024-01-15 10:00:00.000000000',
327
+ author: {
328
+ _account_id: 9999,
329
+ name: 'CI Bot',
330
+ },
331
+ },
332
+ ]
333
+
334
+ server.use(
335
+ http.get('*/a/changes/12345', ({ request }) => {
336
+ const url = new URL(request.url)
337
+ if (url.searchParams.get('o') === 'MESSAGES') {
338
+ return HttpResponse.json(
339
+ { messages },
340
+ {
341
+ headers: { 'Content-Type': 'application/json' },
342
+ },
343
+ )
344
+ }
345
+ return HttpResponse.text('Not Found', { status: 404 })
346
+ }),
347
+ )
348
+
349
+ const effect = buildStatusCommand('12345').pipe(
350
+ Effect.provide(GerritApiServiceLive),
351
+ Effect.provide(createMockConfigLayer()),
352
+ )
353
+
354
+ await Effect.runPromise(effect)
355
+
356
+ expect(capturedStdout.length).toBe(1)
357
+ const output = JSON.parse(capturedStdout[0])
358
+ // Should be running because the most recent Build Started has no verification after it
359
+ expect(output).toEqual({ state: 'running' })
360
+ })
361
+
362
+ test('returns not_found when change does not exist', async () => {
363
+ server.use(
364
+ http.get('*/a/changes/99999', () => {
365
+ return HttpResponse.text('Not Found', { status: 404 })
366
+ }),
367
+ )
368
+
369
+ const effect = buildStatusCommand('99999').pipe(
370
+ Effect.provide(GerritApiServiceLive),
371
+ Effect.provide(createMockConfigLayer()),
372
+ )
373
+
374
+ await Effect.runPromise(effect)
375
+
376
+ expect(capturedStdout.length).toBe(1)
377
+ const output = JSON.parse(capturedStdout[0])
378
+ expect(output).toEqual({ state: 'not_found' })
379
+ })
380
+
381
+ test('handles empty message list', async () => {
382
+ server.use(
383
+ http.get('*/a/changes/12345', ({ request }) => {
384
+ const url = new URL(request.url)
385
+ if (url.searchParams.get('o') === 'MESSAGES') {
386
+ return HttpResponse.json(
387
+ { messages: [] },
388
+ {
389
+ headers: { 'Content-Type': 'application/json' },
390
+ },
391
+ )
392
+ }
393
+ return HttpResponse.text('Not Found', { status: 404 })
394
+ }),
395
+ )
396
+
397
+ const effect = buildStatusCommand('12345').pipe(
398
+ Effect.provide(GerritApiServiceLive),
399
+ Effect.provide(createMockConfigLayer()),
400
+ )
401
+
402
+ await Effect.runPromise(effect)
403
+
404
+ expect(capturedStdout.length).toBe(1)
405
+ const output = JSON.parse(capturedStdout[0])
406
+ // Empty messages means change exists but has no activity - returns pending
407
+ expect(output).toEqual({ state: 'pending' })
408
+ })
409
+
410
+ test('returns first match when both Verified+1 and Verified-1 after Build Started', async () => {
411
+ const messages: MessageInfo[] = [
412
+ {
413
+ id: 'msg1',
414
+ message: 'Build Started',
415
+ date: '2024-01-15 10:00:00.000000000',
416
+ author: {
417
+ _account_id: 9999,
418
+ name: 'CI Bot',
419
+ },
420
+ },
421
+ {
422
+ id: 'msg2',
423
+ message: 'Patch Set 1: Verified-1',
424
+ date: '2024-01-15 10:15:00.000000000',
425
+ author: {
426
+ _account_id: 9999,
427
+ name: 'CI Bot',
428
+ },
429
+ },
430
+ {
431
+ id: 'msg3',
432
+ message: 'Patch Set 2: Verified+1',
433
+ date: '2024-01-15 10:30:00.000000000',
434
+ author: {
435
+ _account_id: 9999,
436
+ name: 'CI Bot',
437
+ },
438
+ },
439
+ ]
440
+
441
+ server.use(
442
+ http.get('*/a/changes/12345', ({ request }) => {
443
+ const url = new URL(request.url)
444
+ if (url.searchParams.get('o') === 'MESSAGES') {
445
+ return HttpResponse.json(
446
+ { messages },
447
+ {
448
+ headers: { 'Content-Type': 'application/json' },
449
+ },
450
+ )
451
+ }
452
+ return HttpResponse.text('Not Found', { status: 404 })
453
+ }),
454
+ )
455
+
456
+ const effect = buildStatusCommand('12345').pipe(
457
+ Effect.provide(GerritApiServiceLive),
458
+ Effect.provide(createMockConfigLayer()),
459
+ )
460
+
461
+ await Effect.runPromise(effect)
462
+
463
+ expect(capturedStdout.length).toBe(1)
464
+ const output = JSON.parse(capturedStdout[0])
465
+ // Should return first verification result (failure)
466
+ expect(output).toEqual({ state: 'failure' })
467
+ })
468
+
469
+ test('does not match malformed verification messages', async () => {
470
+ const messages: MessageInfo[] = [
471
+ {
472
+ id: 'msg1',
473
+ message: 'Build Started',
474
+ date: '2024-01-15 10:00:00.000000000',
475
+ author: {
476
+ _account_id: 9999,
477
+ name: 'CI Bot',
478
+ },
479
+ },
480
+ {
481
+ id: 'msg2',
482
+ message: 'Please verify this +1 thanks',
483
+ date: '2024-01-15 10:15:00.000000000',
484
+ author: {
485
+ _account_id: 1001,
486
+ name: 'Reviewer',
487
+ },
488
+ },
489
+ {
490
+ id: 'msg3',
491
+ message: 'We are not verified -1 yet',
492
+ date: '2024-01-15 10:20:00.000000000',
493
+ author: {
494
+ _account_id: 1002,
495
+ name: 'Reviewer',
496
+ },
497
+ },
498
+ ]
499
+
500
+ server.use(
501
+ http.get('*/a/changes/12345', ({ request }) => {
502
+ const url = new URL(request.url)
503
+ if (url.searchParams.get('o') === 'MESSAGES') {
504
+ return HttpResponse.json(
505
+ { messages },
506
+ {
507
+ headers: { 'Content-Type': 'application/json' },
508
+ },
509
+ )
510
+ }
511
+ return HttpResponse.text('Not Found', { status: 404 })
512
+ }),
513
+ )
514
+
515
+ const effect = buildStatusCommand('12345').pipe(
516
+ Effect.provide(GerritApiServiceLive),
517
+ Effect.provide(createMockConfigLayer()),
518
+ )
519
+
520
+ await Effect.runPromise(effect)
521
+
522
+ expect(capturedStdout.length).toBe(1)
523
+ const output = JSON.parse(capturedStdout[0])
524
+ // Malformed messages should not match, so build is still running
525
+ expect(output).toEqual({ state: 'running' })
526
+ })
527
+
528
+ test('handles network error (500)', async () => {
529
+ server.use(
530
+ http.get('*/a/changes/12345', () => {
531
+ return HttpResponse.text('Internal Server Error', { status: 500 })
532
+ }),
533
+ )
534
+
535
+ const effect = buildStatusCommand('12345').pipe(
536
+ Effect.provide(GerritApiServiceLive),
537
+ Effect.provide(createMockConfigLayer()),
538
+ )
539
+
540
+ try {
541
+ await Effect.runPromise(effect)
542
+ } catch {
543
+ // Should throw error and call process.exit with code 3 for API errors
544
+ expect(mockProcessExit).toHaveBeenCalledWith(3)
545
+ expect(capturedErrors.length).toBeGreaterThan(0)
546
+ }
547
+ })
548
+
549
+ test('handles same timestamp for Build Started and Verified', async () => {
550
+ const sameTimestamp = '2024-01-15 10:00:00.000000000'
551
+ const messages: MessageInfo[] = [
552
+ {
553
+ id: 'msg1',
554
+ message: 'Build Started',
555
+ date: sameTimestamp,
556
+ author: {
557
+ _account_id: 9999,
558
+ name: 'CI Bot',
559
+ },
560
+ },
561
+ {
562
+ id: 'msg2',
563
+ message: 'Patch Set 1: Verified+1',
564
+ date: sameTimestamp,
565
+ author: {
566
+ _account_id: 9999,
567
+ name: 'CI Bot',
568
+ },
569
+ },
570
+ ]
571
+
572
+ server.use(
573
+ http.get('*/a/changes/12345', ({ request }) => {
574
+ const url = new URL(request.url)
575
+ if (url.searchParams.get('o') === 'MESSAGES') {
576
+ return HttpResponse.json(
577
+ { messages },
578
+ {
579
+ headers: { 'Content-Type': 'application/json' },
580
+ },
581
+ )
582
+ }
583
+ return HttpResponse.text('Not Found', { status: 404 })
584
+ }),
585
+ )
586
+
587
+ const effect = buildStatusCommand('12345').pipe(
588
+ Effect.provide(GerritApiServiceLive),
589
+ Effect.provide(createMockConfigLayer()),
590
+ )
591
+
592
+ await Effect.runPromise(effect)
593
+
594
+ expect(capturedStdout.length).toBe(1)
595
+ const output = JSON.parse(capturedStdout[0])
596
+ // Same timestamp means Verified is not after Build Started, so running
597
+ expect(output).toEqual({ state: 'running' })
598
+ })
599
+
600
+ test('matches Build Started with different spacing', async () => {
601
+ const messages: MessageInfo[] = [
602
+ {
603
+ id: 'msg1',
604
+ message: 'Build Started', // Extra space
605
+ date: '2024-01-15 10:00:00.000000000',
606
+ author: {
607
+ _account_id: 9999,
608
+ name: 'CI Bot',
609
+ },
610
+ },
611
+ ]
612
+
613
+ server.use(
614
+ http.get('*/a/changes/12345', ({ request }) => {
615
+ const url = new URL(request.url)
616
+ if (url.searchParams.get('o') === 'MESSAGES') {
617
+ return HttpResponse.json(
618
+ { messages },
619
+ {
620
+ headers: { 'Content-Type': 'application/json' },
621
+ },
622
+ )
623
+ }
624
+ return HttpResponse.text('Not Found', { status: 404 })
625
+ }),
626
+ )
627
+
628
+ const effect = buildStatusCommand('12345').pipe(
629
+ Effect.provide(GerritApiServiceLive),
630
+ Effect.provide(createMockConfigLayer()),
631
+ )
632
+
633
+ await Effect.runPromise(effect)
634
+
635
+ expect(capturedStdout.length).toBe(1)
636
+ const output = JSON.parse(capturedStdout[0])
637
+ // Regex should handle extra whitespace
638
+ expect(output).toEqual({ state: 'running' })
639
+ })
640
+
641
+ test('ignores verification from older patchset when newer patchset build is running', async () => {
642
+ // This test replicates the bug scenario:
643
+ // - PS 3 build started, then PS 4 build started
644
+ // - PS 3 verification (-1) comes AFTER PS 4 build started
645
+ // - Should return "running" because PS 4 has no verification yet
646
+ const messages: MessageInfo[] = [
647
+ {
648
+ id: 'msg1',
649
+ message: 'Build Started https://jenkins.example.com/job/123/',
650
+ date: '2024-01-15 11:12:00.000000000',
651
+ _revision_number: 2,
652
+ author: {
653
+ _account_id: 9999,
654
+ name: 'Service Cloud Jenkins',
655
+ },
656
+ },
657
+ {
658
+ id: 'msg2',
659
+ message: 'Patch Set 2: Verified -1\n\nBuild Failed',
660
+ date: '2024-01-15 11:23:00.000000000',
661
+ _revision_number: 2,
662
+ author: {
663
+ _account_id: 9999,
664
+ name: 'Service Cloud Jenkins',
665
+ },
666
+ },
667
+ {
668
+ id: 'msg3',
669
+ message: 'Build Started https://jenkins.example.com/job/456/',
670
+ date: '2024-01-15 13:57:00.000000000',
671
+ _revision_number: 3,
672
+ author: {
673
+ _account_id: 9999,
674
+ name: 'Service Cloud Jenkins',
675
+ },
676
+ },
677
+ {
678
+ id: 'msg4',
679
+ message: 'Build Started https://jenkins.example.com/job/789/',
680
+ date: '2024-01-15 14:02:00.000000000',
681
+ _revision_number: 4,
682
+ author: {
683
+ _account_id: 9999,
684
+ name: 'Service Cloud Jenkins',
685
+ },
686
+ },
687
+ {
688
+ id: 'msg5',
689
+ message: 'Patch Set 3: Verified -1\n\nBuild Failed : ABORTED',
690
+ date: '2024-01-15 14:03:00.000000000',
691
+ _revision_number: 3,
692
+ author: {
693
+ _account_id: 9999,
694
+ name: 'Service Cloud Jenkins',
695
+ },
696
+ },
697
+ ]
698
+
699
+ server.use(
700
+ http.get('*/a/changes/12345', ({ request }) => {
701
+ const url = new URL(request.url)
702
+ if (url.searchParams.get('o') === 'MESSAGES') {
703
+ return HttpResponse.json(
704
+ { messages },
705
+ {
706
+ headers: { 'Content-Type': 'application/json' },
707
+ },
708
+ )
709
+ }
710
+ return HttpResponse.text('Not Found', { status: 404 })
711
+ }),
712
+ )
713
+
714
+ const effect = buildStatusCommand('12345').pipe(
715
+ Effect.provide(GerritApiServiceLive),
716
+ Effect.provide(createMockConfigLayer()),
717
+ )
718
+
719
+ await Effect.runPromise(effect)
720
+
721
+ expect(capturedStdout.length).toBe(1)
722
+ const output = JSON.parse(capturedStdout[0])
723
+ // PS 4 build started at 14:02, PS 3 verification at 14:03 should be IGNORED
724
+ // because it's for a different revision. PS 4 build is still running.
725
+ expect(output).toEqual({ state: 'running' })
726
+ })
727
+
728
+ test('returns success when verification matches the latest patchset', async () => {
729
+ const messages: MessageInfo[] = [
730
+ {
731
+ id: 'msg1',
732
+ message: 'Build Started',
733
+ date: '2024-01-15 10:00:00.000000000',
734
+ _revision_number: 1,
735
+ author: {
736
+ _account_id: 9999,
737
+ name: 'CI Bot',
738
+ },
739
+ },
740
+ {
741
+ id: 'msg2',
742
+ message: 'Build Started',
743
+ date: '2024-01-15 11:00:00.000000000',
744
+ _revision_number: 2,
745
+ author: {
746
+ _account_id: 9999,
747
+ name: 'CI Bot',
748
+ },
749
+ },
750
+ {
751
+ id: 'msg3',
752
+ message: 'Patch Set 2: Verified+1',
753
+ date: '2024-01-15 11:15:00.000000000',
754
+ _revision_number: 2,
755
+ author: {
756
+ _account_id: 9999,
757
+ name: 'CI Bot',
758
+ },
759
+ },
760
+ ]
761
+
762
+ server.use(
763
+ http.get('*/a/changes/12345', ({ request }) => {
764
+ const url = new URL(request.url)
765
+ if (url.searchParams.get('o') === 'MESSAGES') {
766
+ return HttpResponse.json(
767
+ { messages },
768
+ {
769
+ headers: { 'Content-Type': 'application/json' },
770
+ },
771
+ )
772
+ }
773
+ return HttpResponse.text('Not Found', { status: 404 })
774
+ }),
775
+ )
776
+
777
+ const effect = buildStatusCommand('12345').pipe(
778
+ Effect.provide(GerritApiServiceLive),
779
+ Effect.provide(createMockConfigLayer()),
780
+ )
781
+
782
+ await Effect.runPromise(effect)
783
+
784
+ expect(capturedStdout.length).toBe(1)
785
+ const output = JSON.parse(capturedStdout[0])
786
+ // PS 2 build started at 11:00, PS 2 verification at 11:15 - same revision, success
787
+ expect(output).toEqual({ state: 'success' })
788
+ })
789
+ })