@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,491 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
4
+ import { formatCommentsPretty } from '@/utils/comment-formatters'
5
+ import { getDiffContext } from '@/utils/diff-context'
6
+ import { formatDiffPretty } from '@/utils/diff-formatters'
7
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
+ import { formatDate } from '@/utils/formatters'
9
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
+ import { writeFileSync } from 'node:fs'
11
+
12
+ export const SHOW_HELP_TEXT = `
13
+ Examples:
14
+ # Show specific change (using change number)
15
+ $ ger show 392385
16
+
17
+ # Show specific change (using Change-ID)
18
+ $ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
19
+
20
+ # Auto-detect Change-ID from HEAD commit
21
+ $ ger show
22
+ $ ger show --xml
23
+ $ ger show --json
24
+
25
+ # Extract build failure URL with jq
26
+ $ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
27
+
28
+ Note: When no change-id is provided, it will be automatically extracted from the
29
+ Change-ID footer in your HEAD commit. You must be in a git repository with
30
+ a commit that has a Change-ID.`
31
+
32
+ interface ShowOptions {
33
+ xml?: boolean
34
+ json?: boolean
35
+ }
36
+
37
+ interface ChangeDetails {
38
+ id: string
39
+ number: number
40
+ subject: string
41
+ status: string
42
+ project: string
43
+ branch: string
44
+ owner: {
45
+ name?: string
46
+ email?: string
47
+ }
48
+ created?: string
49
+ updated?: string
50
+ commitMessage: string
51
+ }
52
+
53
+ const getChangeDetails = (
54
+ changeId: string,
55
+ ): Effect.Effect<ChangeDetails, ApiError, GerritApiService> =>
56
+ Effect.gen(function* () {
57
+ const gerritApi = yield* GerritApiService
58
+ const change = yield* gerritApi.getChange(changeId)
59
+
60
+ return {
61
+ id: change.change_id,
62
+ number: change._number,
63
+ subject: change.subject,
64
+ status: change.status,
65
+ project: change.project,
66
+ branch: change.branch,
67
+ owner: {
68
+ name: change.owner?.name,
69
+ email: change.owner?.email,
70
+ },
71
+ created: change.created,
72
+ updated: change.updated,
73
+ commitMessage: change.subject, // For now, using subject as commit message
74
+ }
75
+ })
76
+
77
+ const getDiffForChange = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
78
+ Effect.gen(function* () {
79
+ const gerritApi = yield* GerritApiService
80
+ const diff = yield* gerritApi.getDiff(changeId, { format: 'unified' })
81
+ return typeof diff === 'string' ? diff : JSON.stringify(diff, null, 2)
82
+ })
83
+
84
+ const getCommentsAndMessagesForChange = (
85
+ changeId: string,
86
+ ): Effect.Effect<
87
+ { comments: CommentInfo[]; messages: MessageInfo[] },
88
+ ApiError,
89
+ GerritApiService
90
+ > =>
91
+ Effect.gen(function* () {
92
+ const gerritApi = yield* GerritApiService
93
+
94
+ // Get both inline comments and review messages concurrently
95
+ const [comments, messages] = yield* Effect.all(
96
+ [gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
97
+ { concurrency: 'unbounded' },
98
+ )
99
+
100
+ // Flatten all inline comments from all files
101
+ const allComments: CommentInfo[] = []
102
+ for (const [path, fileComments] of Object.entries(comments)) {
103
+ for (const comment of fileComments) {
104
+ allComments.push({
105
+ ...comment,
106
+ path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
107
+ })
108
+ }
109
+ }
110
+
111
+ // Sort inline comments by date (ascending - oldest first)
112
+ allComments.sort((a, b) => {
113
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
114
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
115
+ return dateA - dateB
116
+ })
117
+
118
+ // Sort messages by date (ascending - oldest first)
119
+ const sortedMessages = [...messages].sort((a, b) => {
120
+ const dateA = new Date(a.date).getTime()
121
+ const dateB = new Date(b.date).getTime()
122
+ return dateA - dateB
123
+ })
124
+
125
+ return { comments: allComments, messages: sortedMessages }
126
+ })
127
+
128
+ const formatShowPretty = (
129
+ changeDetails: ChangeDetails,
130
+ diff: string,
131
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
132
+ messages: MessageInfo[],
133
+ ): void => {
134
+ // Change details header
135
+ console.log('━'.repeat(80))
136
+ console.log(`📋 Change ${changeDetails.number}: ${changeDetails.subject}`)
137
+ console.log('━'.repeat(80))
138
+ console.log()
139
+
140
+ // Metadata
141
+ console.log('📝 Details:')
142
+ console.log(` Project: ${changeDetails.project}`)
143
+ console.log(` Branch: ${changeDetails.branch}`)
144
+ console.log(` Status: ${changeDetails.status}`)
145
+ console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
146
+ console.log(
147
+ ` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
148
+ )
149
+ console.log(
150
+ ` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`,
151
+ )
152
+ console.log(` Change-Id: ${changeDetails.id}`)
153
+ console.log()
154
+
155
+ // Diff section
156
+ console.log('🔍 Diff:')
157
+ console.log('─'.repeat(40))
158
+ console.log(formatDiffPretty(diff))
159
+ console.log()
160
+
161
+ // Comments and Messages section
162
+ const hasComments = commentsWithContext.length > 0
163
+ const hasMessages = messages.length > 0
164
+
165
+ if (hasComments) {
166
+ console.log('💬 Inline Comments:')
167
+ console.log('─'.repeat(40))
168
+ formatCommentsPretty(commentsWithContext)
169
+ console.log()
170
+ }
171
+
172
+ if (hasMessages) {
173
+ console.log('📝 Review Activity:')
174
+ console.log('─'.repeat(40))
175
+ for (const message of messages) {
176
+ const author = message.author?.name || 'Unknown'
177
+ const date = formatDate(message.date)
178
+ const cleanMessage = message.message.trim()
179
+
180
+ // Skip very short automated messages
181
+ if (
182
+ cleanMessage.length < 10 &&
183
+ (cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
184
+ ) {
185
+ continue
186
+ }
187
+
188
+ console.log(`📅 ${date} - ${author}`)
189
+ console.log(` ${cleanMessage}`)
190
+ console.log()
191
+ }
192
+ }
193
+
194
+ if (!hasComments && !hasMessages) {
195
+ console.log('💬 Comments & Activity:')
196
+ console.log('─'.repeat(40))
197
+ console.log('No comments or review activity found.')
198
+ }
199
+ }
200
+
201
+ // Helper to remove undefined values from objects
202
+ const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => {
203
+ return Object.fromEntries(
204
+ Object.entries(obj).filter(([_, value]) => value !== undefined),
205
+ ) as Partial<T>
206
+ }
207
+
208
+ const formatShowJson = async (
209
+ changeDetails: ChangeDetails,
210
+ diff: string,
211
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
212
+ messages: MessageInfo[],
213
+ ): Promise<void> => {
214
+ const output = {
215
+ status: 'success',
216
+ change: removeUndefined({
217
+ id: changeDetails.id,
218
+ number: changeDetails.number,
219
+ subject: changeDetails.subject,
220
+ status: changeDetails.status,
221
+ project: changeDetails.project,
222
+ branch: changeDetails.branch,
223
+ owner: removeUndefined(changeDetails.owner),
224
+ created: changeDetails.created,
225
+ updated: changeDetails.updated,
226
+ }),
227
+ diff,
228
+ comments: commentsWithContext.map(({ comment, context }) =>
229
+ removeUndefined({
230
+ id: comment.id,
231
+ path: comment.path,
232
+ line: comment.line,
233
+ range: comment.range,
234
+ author: comment.author
235
+ ? removeUndefined({
236
+ name: comment.author.name,
237
+ email: comment.author.email,
238
+ account_id: comment.author._account_id,
239
+ })
240
+ : undefined,
241
+ updated: comment.updated,
242
+ message: comment.message,
243
+ unresolved: comment.unresolved,
244
+ in_reply_to: comment.in_reply_to,
245
+ context,
246
+ }),
247
+ ),
248
+ messages: messages.map((message) =>
249
+ removeUndefined({
250
+ id: message.id,
251
+ author: message.author
252
+ ? removeUndefined({
253
+ name: message.author.name,
254
+ email: message.author.email,
255
+ account_id: message.author._account_id,
256
+ })
257
+ : undefined,
258
+ date: message.date,
259
+ message: message.message,
260
+ revision: message._revision_number,
261
+ tag: message.tag,
262
+ }),
263
+ ),
264
+ }
265
+
266
+ const jsonOutput = JSON.stringify(output, null, 2) + '\n'
267
+ // Write to stdout and ensure all data is flushed before process exits
268
+ // Using process.stdout.write with drain handling for large payloads
269
+ return new Promise<void>((resolve, reject) => {
270
+ const written = process.stdout.write(jsonOutput, (err) => {
271
+ if (err) {
272
+ reject(err)
273
+ } else {
274
+ resolve()
275
+ }
276
+ })
277
+
278
+ if (!written) {
279
+ // If write returned false, buffer is full, wait for drain
280
+ process.stdout.once('drain', resolve)
281
+ process.stdout.once('error', reject)
282
+ }
283
+ })
284
+ }
285
+
286
+ const formatShowXml = async (
287
+ changeDetails: ChangeDetails,
288
+ diff: string,
289
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
290
+ messages: MessageInfo[],
291
+ ): Promise<void> => {
292
+ // Build complete XML output as a single string to avoid multiple writes
293
+ const xmlParts: string[] = []
294
+ xmlParts.push(`<?xml version="1.0" encoding="UTF-8"?>`)
295
+ xmlParts.push(`<show_result>`)
296
+ xmlParts.push(` <status>success</status>`)
297
+ xmlParts.push(` <change>`)
298
+ xmlParts.push(` <id>${escapeXML(changeDetails.id)}</id>`)
299
+ xmlParts.push(` <number>${changeDetails.number}</number>`)
300
+ xmlParts.push(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
301
+ xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
302
+ xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
303
+ xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
304
+ xmlParts.push(` <owner>`)
305
+ if (changeDetails.owner.name) {
306
+ xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
307
+ }
308
+ if (changeDetails.owner.email) {
309
+ xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
310
+ }
311
+ xmlParts.push(` </owner>`)
312
+ xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`)
313
+ xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
314
+ xmlParts.push(` </change>`)
315
+ xmlParts.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
316
+
317
+ // Comments section
318
+ xmlParts.push(` <comments>`)
319
+ xmlParts.push(` <count>${commentsWithContext.length}</count>`)
320
+ for (const { comment } of commentsWithContext) {
321
+ xmlParts.push(` <comment>`)
322
+ if (comment.id) xmlParts.push(` <id>${escapeXML(comment.id)}</id>`)
323
+ if (comment.path) xmlParts.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
324
+ if (comment.line) xmlParts.push(` <line>${comment.line}</line>`)
325
+ if (comment.author?.name) {
326
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
327
+ }
328
+ if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`)
329
+ if (comment.message) {
330
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
331
+ }
332
+ if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`)
333
+ xmlParts.push(` </comment>`)
334
+ }
335
+ xmlParts.push(` </comments>`)
336
+
337
+ // Messages section
338
+ xmlParts.push(` <messages>`)
339
+ xmlParts.push(` <count>${messages.length}</count>`)
340
+ for (const message of messages) {
341
+ xmlParts.push(` <message>`)
342
+ xmlParts.push(` <id>${escapeXML(message.id)}</id>`)
343
+ if (message.author?.name) {
344
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
345
+ }
346
+ if (message.author?._account_id) {
347
+ xmlParts.push(` <author_id>${message.author._account_id}</author_id>`)
348
+ }
349
+ xmlParts.push(` <date>${escapeXML(message.date)}</date>`)
350
+ if (message._revision_number) {
351
+ xmlParts.push(` <revision>${message._revision_number}</revision>`)
352
+ }
353
+ if (message.tag) {
354
+ xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`)
355
+ }
356
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
357
+ xmlParts.push(` </message>`)
358
+ }
359
+ xmlParts.push(` </messages>`)
360
+ xmlParts.push(`</show_result>`)
361
+
362
+ const xmlOutput = xmlParts.join('\n') + '\n'
363
+ // Write to stdout with proper drain handling for large payloads
364
+ return new Promise<void>((resolve, reject) => {
365
+ const written = process.stdout.write(xmlOutput, (err) => {
366
+ if (err) {
367
+ reject(err)
368
+ } else {
369
+ resolve()
370
+ }
371
+ })
372
+
373
+ if (!written) {
374
+ process.stdout.once('drain', resolve)
375
+ process.stdout.once('error', reject)
376
+ }
377
+ })
378
+ }
379
+
380
+ export const showCommand = (
381
+ changeId: string | undefined,
382
+ options: ShowOptions,
383
+ ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
384
+ Effect.gen(function* () {
385
+ // Auto-detect Change-ID from HEAD commit if not provided
386
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
387
+
388
+ // Fetch all data concurrently
389
+ const [changeDetails, diff, commentsAndMessages] = yield* Effect.all(
390
+ [
391
+ getChangeDetails(resolvedChangeId),
392
+ getDiffForChange(resolvedChangeId),
393
+ getCommentsAndMessagesForChange(resolvedChangeId),
394
+ ],
395
+ { concurrency: 'unbounded' },
396
+ )
397
+
398
+ const { comments, messages } = commentsAndMessages
399
+
400
+ // Get context for each comment using concurrent requests
401
+ const contextEffects = comments.map((comment) =>
402
+ comment.path && comment.line
403
+ ? getDiffContext(resolvedChangeId, comment.path, comment.line).pipe(
404
+ Effect.map((context) => ({ comment, context })),
405
+ // Graceful degradation for diff fetch failures
406
+ Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
407
+ )
408
+ : Effect.succeed({ comment, context: undefined }),
409
+ )
410
+
411
+ // Execute all context fetches concurrently
412
+ const commentsWithContext = yield* Effect.all(contextEffects, {
413
+ concurrency: 'unbounded',
414
+ })
415
+
416
+ // Format output
417
+ if (options.json) {
418
+ yield* Effect.promise(() =>
419
+ formatShowJson(changeDetails, diff, commentsWithContext, messages),
420
+ )
421
+ } else if (options.xml) {
422
+ yield* Effect.promise(() => formatShowXml(changeDetails, diff, commentsWithContext, messages))
423
+ } else {
424
+ formatShowPretty(changeDetails, diff, commentsWithContext, messages)
425
+ }
426
+ }).pipe(
427
+ // Regional error boundary for the entire command
428
+ Effect.catchAll((error) => {
429
+ const errorMessage =
430
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
431
+ ? error.message
432
+ : String(error)
433
+
434
+ if (options.json) {
435
+ return Effect.promise(
436
+ () =>
437
+ new Promise<void>((resolve, reject) => {
438
+ const errorOutput =
439
+ JSON.stringify(
440
+ {
441
+ status: 'error',
442
+ error: errorMessage,
443
+ },
444
+ null,
445
+ 2,
446
+ ) + '\n'
447
+ const written = process.stdout.write(errorOutput, (err) => {
448
+ if (err) {
449
+ reject(err)
450
+ } else {
451
+ resolve()
452
+ }
453
+ })
454
+
455
+ if (!written) {
456
+ // Wait for drain if buffer is full
457
+ process.stdout.once('drain', resolve)
458
+ process.stdout.once('error', reject)
459
+ }
460
+ }),
461
+ )
462
+ } else if (options.xml) {
463
+ return Effect.promise(
464
+ () =>
465
+ new Promise<void>((resolve, reject) => {
466
+ const xmlError =
467
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
468
+ `<show_result>\n` +
469
+ ` <status>error</status>\n` +
470
+ ` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>\n` +
471
+ `</show_result>\n`
472
+ const written = process.stdout.write(xmlError, (err) => {
473
+ if (err) {
474
+ reject(err)
475
+ } else {
476
+ resolve()
477
+ }
478
+ })
479
+
480
+ if (!written) {
481
+ process.stdout.once('drain', resolve)
482
+ process.stdout.once('error', reject)
483
+ }
484
+ }),
485
+ )
486
+ } else {
487
+ console.error(`✗ Error: ${errorMessage}`)
488
+ }
489
+ return Effect.succeed(undefined)
490
+ }),
491
+ )
@@ -0,0 +1,35 @@
1
+ import { Effect } from 'effect'
2
+ import { GerritApiService } from '@/api/gerrit'
3
+
4
+ interface StatusOptions {
5
+ xml?: boolean
6
+ }
7
+
8
+ export const statusCommand = (
9
+ options: StatusOptions,
10
+ ): Effect.Effect<void, Error, GerritApiService> =>
11
+ Effect.gen(function* () {
12
+ const apiService = yield* GerritApiService
13
+
14
+ const isConnected = yield* apiService.testConnection
15
+
16
+ if (options.xml) {
17
+ // XML output for LLM consumption
18
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
19
+ console.log(`<status_result>`)
20
+ console.log(` <connected>${isConnected}</connected>`)
21
+ console.log(`</status_result>`)
22
+ } else {
23
+ // Pretty output by default
24
+ if (isConnected) {
25
+ console.log('✓ Connected to Gerrit successfully!')
26
+ } else {
27
+ console.log('✗ Failed to connect to Gerrit')
28
+ console.log('Please check your credentials and network connection')
29
+ }
30
+ }
31
+
32
+ if (!isConnected) {
33
+ yield* Effect.fail(new Error('Connection failed'))
34
+ }
35
+ })
@@ -0,0 +1,108 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+
4
+ interface SubmitOptions {
5
+ xml?: boolean
6
+ }
7
+
8
+ /**
9
+ * Submits a Gerrit change for merging after verifying it meets submit requirements.
10
+ *
11
+ * Pre-validates that the change has required approvals and is in the correct state
12
+ * before attempting submission.
13
+ *
14
+ * @param changeId - Change number or Change-ID to submit
15
+ * @param options - Configuration options
16
+ * @param options.xml - Whether to output in XML format for LLM consumption
17
+ * @returns Effect that completes when the change is submitted or validation fails
18
+ */
19
+ export const submitCommand = (
20
+ changeId?: string,
21
+ options: SubmitOptions = {},
22
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
23
+ Effect.gen(function* () {
24
+ const gerritApi = yield* GerritApiService
25
+
26
+ if (!changeId || changeId.trim() === '') {
27
+ console.error('✗ Change ID is required')
28
+ console.error(' Usage: ger submit <change-id>')
29
+ return
30
+ }
31
+
32
+ // Pre-check: Fetch change to verify it's submittable
33
+ const change = yield* gerritApi.getChange(changeId)
34
+
35
+ // Check if the change is submittable
36
+ if (change.submittable === false) {
37
+ const reasons: string[] = []
38
+
39
+ // Check status
40
+ if (change.status !== 'NEW') {
41
+ reasons.push(`Change status is ${change.status} (must be NEW)`)
42
+ }
43
+
44
+ // Check for work in progress
45
+ if (change.work_in_progress) {
46
+ reasons.push('Change is marked as work-in-progress')
47
+ }
48
+
49
+ // Check labels for required approvals
50
+ if (change.labels) {
51
+ const codeReview = change.labels['Code-Review']
52
+ const verified = change.labels['Verified']
53
+
54
+ if (codeReview && !codeReview.approved) {
55
+ reasons.push('Missing Code-Review+2 approval')
56
+ }
57
+
58
+ if (verified && !verified.approved) {
59
+ reasons.push('Missing Verified+1 approval')
60
+ }
61
+ }
62
+
63
+ // If no specific reasons found but not submittable, add generic reason
64
+ if (reasons.length === 0) {
65
+ reasons.push('Change does not meet submit requirements')
66
+ }
67
+
68
+ if (options.xml) {
69
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
70
+ console.log(`<submit_result>`)
71
+ console.log(` <status>error</status>`)
72
+ console.log(` <change_number>${change._number}</change_number>`)
73
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
74
+ console.log(` <submittable>false</submittable>`)
75
+ console.log(` <reasons>`)
76
+ for (const reason of reasons) {
77
+ console.log(` <reason><![CDATA[${reason}]]></reason>`)
78
+ }
79
+ console.log(` </reasons>`)
80
+ console.log(`</submit_result>`)
81
+ } else {
82
+ console.error(`✗ Change ${change._number} cannot be submitted:`)
83
+ console.error(` ${change.subject}`)
84
+ console.error(``)
85
+ console.error(` Reasons:`)
86
+ for (const reason of reasons) {
87
+ console.error(` - ${reason}`)
88
+ }
89
+ }
90
+ return
91
+ }
92
+
93
+ // Change is submittable, proceed with submission
94
+ const result = yield* gerritApi.submitChange(changeId)
95
+
96
+ if (options.xml) {
97
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
98
+ console.log(`<submit_result>`)
99
+ console.log(` <status>success</status>`)
100
+ console.log(` <change_number>${change._number}</change_number>`)
101
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
102
+ console.log(` <submit_status>${result.status}</submit_status>`)
103
+ console.log(`</submit_result>`)
104
+ } else {
105
+ console.log(`✓ Submitted change ${change._number}: ${change.subject}`)
106
+ console.log(` Status: ${result.status}`)
107
+ }
108
+ })