@aaronshaf/ger 0.1.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 (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -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 +78 -0
  6. package/.github/workflows/claude.yml +64 -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 +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,230 @@
1
+ import chalk from 'chalk'
2
+ import { Effect, pipe, Console } from 'effect'
3
+ import {
4
+ ConfigService,
5
+ ConfigServiceLive,
6
+ ConfigError,
7
+ type ConfigServiceImpl,
8
+ } from '@/services/config'
9
+ import type { GerritCredentials } from '@/schemas/gerrit'
10
+ import { AppConfig } from '@/schemas/config'
11
+ import { Schema } from '@effect/schema'
12
+ import { input, password } from '@inquirer/prompts'
13
+ import { spawn } from 'node:child_process'
14
+
15
+ // Check if a command exists on the system
16
+ const checkCommandExists = (command: string): Promise<boolean> =>
17
+ new Promise((resolve) => {
18
+ const child = spawn('which', [command], { stdio: 'ignore' })
19
+ child.on('close', (code) => {
20
+ resolve(code === 0)
21
+ })
22
+ child.on('error', () => {
23
+ resolve(false)
24
+ })
25
+ })
26
+
27
+ // AI tools to check for in order of preference
28
+ const AI_TOOLS = ['claude', 'llm', 'opencode', 'gemini'] as const
29
+
30
+ // Effect wrapper for detecting available AI tools
31
+ const detectAvailableAITools = () =>
32
+ Effect.tryPromise({
33
+ try: async () => {
34
+ const availableTools: string[] = []
35
+
36
+ for (const tool of AI_TOOLS) {
37
+ const exists = await checkCommandExists(tool)
38
+ if (exists) {
39
+ availableTools.push(tool)
40
+ }
41
+ }
42
+
43
+ return availableTools
44
+ },
45
+ catch: (error) => new ConfigError({ message: `Failed to detect AI tools: ${error}` }),
46
+ })
47
+
48
+ // Effect wrapper for getting existing config
49
+ const getExistingConfig = (configService: ConfigServiceImpl) =>
50
+ configService.getFullConfig.pipe(Effect.orElseSucceed(() => null))
51
+
52
+ // Test connection with credentials
53
+ const verifyCredentials = (credentials: GerritCredentials) =>
54
+ Effect.tryPromise({
55
+ try: async () => {
56
+ const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')
57
+ const response = await fetch(`${credentials.host}/a/config/server/version`, {
58
+ headers: { Authorization: `Basic ${auth}` },
59
+ })
60
+
61
+ if (!response.ok) {
62
+ throw new Error(`Authentication failed: ${response.status}`)
63
+ }
64
+
65
+ return response.ok
66
+ },
67
+ catch: (error) => {
68
+ if (error instanceof Error) {
69
+ if (error.message.includes('401')) {
70
+ return new ConfigError({
71
+ message: 'Invalid credentials. Please check your username and password.',
72
+ })
73
+ }
74
+ if (error.message.includes('ENOTFOUND')) {
75
+ return new ConfigError({ message: 'Could not connect to Gerrit. Please check the URL.' })
76
+ }
77
+ return new ConfigError({ message: error.message })
78
+ }
79
+ return new ConfigError({ message: 'Unknown error occurred' })
80
+ },
81
+ })
82
+
83
+ // Pure Effect-based setup implementation using inquirer
84
+ const setupEffect = (configService: ConfigServiceImpl) =>
85
+ pipe(
86
+ Effect.all([getExistingConfig(configService), detectAvailableAITools()]),
87
+ Effect.flatMap(([existingConfig, availableTools]) =>
88
+ pipe(
89
+ Console.log(chalk.bold('🔧 Gerrit CLI Setup')),
90
+ Effect.flatMap(() => Console.log('')),
91
+ Effect.flatMap(() => {
92
+ if (existingConfig) {
93
+ return Console.log(chalk.dim('(Press Enter to keep existing values)'))
94
+ } else {
95
+ return pipe(
96
+ Console.log(chalk.cyan('Please provide your Gerrit connection details:')),
97
+ Effect.flatMap(() =>
98
+ Console.log(chalk.dim('Example URL: https://gerrit.example.com')),
99
+ ),
100
+ Effect.flatMap(() =>
101
+ Console.log(
102
+ chalk.dim(
103
+ 'You can find your HTTP password in Gerrit Settings > HTTP Credentials',
104
+ ),
105
+ ),
106
+ ),
107
+ )
108
+ }
109
+ }),
110
+ Effect.flatMap(() =>
111
+ Effect.tryPromise({
112
+ try: async () => {
113
+ console.log('')
114
+
115
+ // Gerrit Host URL
116
+ const host = await input({
117
+ message: 'Gerrit Host URL (e.g., https://gerrit.example.com)',
118
+ default: existingConfig?.host,
119
+ })
120
+
121
+ // Username
122
+ const username = await input({
123
+ message: 'Username (your Gerrit username)',
124
+ default: existingConfig?.username,
125
+ })
126
+
127
+ // Password - similar to ji's pattern
128
+ const passwordValue =
129
+ (await password({
130
+ message: 'HTTP Password (generated from Gerrit settings)',
131
+ })) ||
132
+ existingConfig?.password ||
133
+ ''
134
+
135
+ console.log('')
136
+ console.log(chalk.yellow('Optional: AI Configuration'))
137
+
138
+ // Show detected AI tools
139
+ if (availableTools.length > 0) {
140
+ console.log(chalk.dim(`Detected AI tools: ${availableTools.join(', ')}`))
141
+ }
142
+
143
+ // Get default suggestion
144
+ const defaultCommand =
145
+ existingConfig?.aiTool ||
146
+ (availableTools.includes('claude') ? 'claude' : availableTools[0]) ||
147
+ ''
148
+
149
+ // AI tool command with smart default
150
+ const aiToolCommand = await input({
151
+ message:
152
+ availableTools.length > 0
153
+ ? 'AI tool command (detected from system)'
154
+ : 'AI tool command (e.g., claude, llm, opencode, gemini)',
155
+ default: defaultCommand || 'claude',
156
+ })
157
+
158
+ // Build flat config
159
+ const configData = {
160
+ host: host.trim().replace(/\/$/, ''), // Remove trailing slash
161
+ username: username.trim(),
162
+ password: passwordValue,
163
+ ...(aiToolCommand && {
164
+ aiTool: aiToolCommand,
165
+ }),
166
+ aiAutoDetect: !aiToolCommand,
167
+ }
168
+
169
+ // Validate config using Schema instead of type assertion
170
+ const fullConfig = Schema.decodeUnknownSync(AppConfig)(configData)
171
+
172
+ return fullConfig
173
+ },
174
+ catch: (error) => {
175
+ if (error instanceof Error && error.message.includes('User force closed')) {
176
+ console.log(`\n${chalk.yellow('Setup cancelled')}`)
177
+ process.exit(0)
178
+ }
179
+ return new ConfigError({
180
+ message: error instanceof Error ? error.message : 'Failed to get user input',
181
+ })
182
+ },
183
+ }),
184
+ ),
185
+ ),
186
+ ),
187
+ Effect.tap(() => Console.log('\nVerifying credentials...')),
188
+ Effect.flatMap((config) =>
189
+ pipe(
190
+ verifyCredentials({
191
+ host: config.host,
192
+ username: config.username,
193
+ password: config.password,
194
+ }),
195
+ Effect.map(() => config),
196
+ ),
197
+ ),
198
+ Effect.tap(() => Console.log(chalk.green('Successfully authenticated'))),
199
+ Effect.flatMap((config) => configService.saveFullConfig(config)),
200
+ Effect.tap(() => Console.log(chalk.green('\nConfiguration saved successfully!'))),
201
+ Effect.tap(() => Console.log('You can now use:')),
202
+ Effect.tap(() => Console.log(' • "ger mine" to view your changes')),
203
+ Effect.tap(() => Console.log(' • "ger show <change-id>" to view change details')),
204
+ Effect.tap(() => Console.log(' • "ger review <change-id>" to review with AI')),
205
+ Effect.catchAll((error) =>
206
+ pipe(
207
+ Console.error(
208
+ chalk.red(
209
+ `\nAuthentication failed: ${error instanceof ConfigError ? error.message : 'Unknown error'}`,
210
+ ),
211
+ ),
212
+ Effect.flatMap(() => Console.error('Please check your credentials and try again.')),
213
+ Effect.flatMap(() => Effect.fail(error)),
214
+ ),
215
+ ),
216
+ )
217
+
218
+ export async function setup() {
219
+ const program = pipe(
220
+ ConfigService,
221
+ Effect.flatMap((configService) => setupEffect(configService)),
222
+ ).pipe(Effect.provide(ConfigServiceLive))
223
+
224
+ try {
225
+ await Effect.runPromise(program)
226
+ } catch {
227
+ // Error already handled and displayed
228
+ process.exit(1)
229
+ }
230
+ }
@@ -0,0 +1,303 @@
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 { sortMessagesByDate } from '@/utils/message-filters'
10
+
11
+ interface ShowOptions {
12
+ xml?: boolean
13
+ }
14
+
15
+ interface ChangeDetails {
16
+ id: string
17
+ number: number
18
+ subject: string
19
+ status: string
20
+ project: string
21
+ branch: string
22
+ owner: {
23
+ name?: string
24
+ email?: string
25
+ }
26
+ created?: string
27
+ updated?: string
28
+ commitMessage: string
29
+ }
30
+
31
+ const getChangeDetails = (
32
+ changeId: string,
33
+ ): Effect.Effect<ChangeDetails, ApiError, GerritApiService> =>
34
+ Effect.gen(function* () {
35
+ const gerritApi = yield* GerritApiService
36
+ const change = yield* gerritApi.getChange(changeId)
37
+
38
+ return {
39
+ id: change.change_id,
40
+ number: change._number,
41
+ subject: change.subject,
42
+ status: change.status,
43
+ project: change.project,
44
+ branch: change.branch,
45
+ owner: {
46
+ name: change.owner?.name,
47
+ email: change.owner?.email,
48
+ },
49
+ created: change.created,
50
+ updated: change.updated,
51
+ commitMessage: change.subject, // For now, using subject as commit message
52
+ }
53
+ })
54
+
55
+ const getDiffForChange = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
56
+ Effect.gen(function* () {
57
+ const gerritApi = yield* GerritApiService
58
+ const diff = yield* gerritApi.getDiff(changeId, { format: 'unified' })
59
+ return typeof diff === 'string' ? diff : JSON.stringify(diff, null, 2)
60
+ })
61
+
62
+ const getCommentsAndMessagesForChange = (
63
+ changeId: string,
64
+ ): Effect.Effect<
65
+ { comments: CommentInfo[]; messages: MessageInfo[] },
66
+ ApiError,
67
+ GerritApiService
68
+ > =>
69
+ Effect.gen(function* () {
70
+ const gerritApi = yield* GerritApiService
71
+
72
+ // Get both inline comments and review messages concurrently
73
+ const [comments, messages] = yield* Effect.all(
74
+ [gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
75
+ { concurrency: 'unbounded' },
76
+ )
77
+
78
+ // Flatten all inline comments from all files
79
+ const allComments: CommentInfo[] = []
80
+ for (const [path, fileComments] of Object.entries(comments)) {
81
+ for (const comment of fileComments) {
82
+ allComments.push({
83
+ ...comment,
84
+ path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
85
+ })
86
+ }
87
+ }
88
+
89
+ // Sort inline comments by path and then by line number
90
+ allComments.sort((a, b) => {
91
+ const pathCompare = (a.path || '').localeCompare(b.path || '')
92
+ if (pathCompare !== 0) return pathCompare
93
+ return (a.line || 0) - (b.line || 0)
94
+ })
95
+
96
+ // Sort messages by date (newest first)
97
+ const sortedMessages = sortMessagesByDate(messages)
98
+
99
+ return { comments: allComments, messages: sortedMessages }
100
+ })
101
+
102
+ const formatShowPretty = (
103
+ changeDetails: ChangeDetails,
104
+ diff: string,
105
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
106
+ messages: MessageInfo[],
107
+ ): void => {
108
+ // Change details header
109
+ console.log('━'.repeat(80))
110
+ console.log(`📋 Change ${changeDetails.number}: ${changeDetails.subject}`)
111
+ console.log('━'.repeat(80))
112
+ console.log()
113
+
114
+ // Metadata
115
+ console.log('📝 Details:')
116
+ console.log(` Project: ${changeDetails.project}`)
117
+ console.log(` Branch: ${changeDetails.branch}`)
118
+ console.log(` Status: ${changeDetails.status}`)
119
+ console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
120
+ console.log(
121
+ ` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
122
+ )
123
+ console.log(
124
+ ` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`,
125
+ )
126
+ console.log(` Change-Id: ${changeDetails.id}`)
127
+ console.log()
128
+
129
+ // Diff section
130
+ console.log('🔍 Diff:')
131
+ console.log('─'.repeat(40))
132
+ console.log(formatDiffPretty(diff))
133
+ console.log()
134
+
135
+ // Comments and Messages section
136
+ const hasComments = commentsWithContext.length > 0
137
+ const hasMessages = messages.length > 0
138
+
139
+ if (hasComments) {
140
+ console.log('💬 Inline Comments:')
141
+ console.log('─'.repeat(40))
142
+ formatCommentsPretty(commentsWithContext)
143
+ console.log()
144
+ }
145
+
146
+ if (hasMessages) {
147
+ console.log('📝 Review Activity:')
148
+ console.log('─'.repeat(40))
149
+ for (const message of messages) {
150
+ const author = message.author?.name || 'Unknown'
151
+ const date = formatDate(message.date)
152
+ const cleanMessage = message.message.trim()
153
+
154
+ // Skip very short automated messages
155
+ if (
156
+ cleanMessage.length < 10 &&
157
+ (cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
158
+ ) {
159
+ continue
160
+ }
161
+
162
+ console.log(`📅 ${date} - ${author}`)
163
+ console.log(` ${cleanMessage}`)
164
+ console.log()
165
+ }
166
+ }
167
+
168
+ if (!hasComments && !hasMessages) {
169
+ console.log('💬 Comments & Activity:')
170
+ console.log('─'.repeat(40))
171
+ console.log('No comments or review activity found.')
172
+ }
173
+ }
174
+
175
+ const formatShowXml = (
176
+ changeDetails: ChangeDetails,
177
+ diff: string,
178
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
179
+ messages: MessageInfo[],
180
+ ): void => {
181
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
182
+ console.log(`<show_result>`)
183
+ console.log(` <status>success</status>`)
184
+ console.log(` <change>`)
185
+ console.log(` <id>${escapeXML(changeDetails.id)}</id>`)
186
+ console.log(` <number>${changeDetails.number}</number>`)
187
+ console.log(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
188
+ console.log(` <status>${escapeXML(changeDetails.status)}</status>`)
189
+ console.log(` <project>${escapeXML(changeDetails.project)}</project>`)
190
+ console.log(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
191
+ console.log(` <owner>`)
192
+ if (changeDetails.owner.name) {
193
+ console.log(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
194
+ }
195
+ if (changeDetails.owner.email) {
196
+ console.log(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
197
+ }
198
+ console.log(` </owner>`)
199
+ console.log(` <created>${escapeXML(changeDetails.created || '')}</created>`)
200
+ console.log(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
201
+ console.log(` </change>`)
202
+ console.log(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
203
+
204
+ // Comments section
205
+ console.log(` <comments>`)
206
+ console.log(` <count>${commentsWithContext.length}</count>`)
207
+ for (const { comment } of commentsWithContext) {
208
+ console.log(` <comment>`)
209
+ if (comment.id) console.log(` <id>${escapeXML(comment.id)}</id>`)
210
+ if (comment.path) console.log(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
211
+ if (comment.line) console.log(` <line>${comment.line}</line>`)
212
+ if (comment.author?.name) {
213
+ console.log(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
214
+ }
215
+ if (comment.updated) console.log(` <updated>${escapeXML(comment.updated)}</updated>`)
216
+ if (comment.message) {
217
+ console.log(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
218
+ }
219
+ if (comment.unresolved) console.log(` <unresolved>true</unresolved>`)
220
+ console.log(` </comment>`)
221
+ }
222
+ console.log(` </comments>`)
223
+
224
+ // Messages section
225
+ console.log(` <messages>`)
226
+ console.log(` <count>${messages.length}</count>`)
227
+ for (const message of messages) {
228
+ console.log(` <message>`)
229
+ console.log(` <id>${escapeXML(message.id)}</id>`)
230
+ if (message.author?.name) {
231
+ console.log(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
232
+ }
233
+ if (message.author?._account_id) {
234
+ console.log(` <author_id>${message.author._account_id}</author_id>`)
235
+ }
236
+ console.log(` <date>${escapeXML(message.date)}</date>`)
237
+ if (message._revision_number) {
238
+ console.log(` <revision>${message._revision_number}</revision>`)
239
+ }
240
+ if (message.tag) {
241
+ console.log(` <tag>${escapeXML(message.tag)}</tag>`)
242
+ }
243
+ console.log(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
244
+ console.log(` </message>`)
245
+ }
246
+ console.log(` </messages>`)
247
+ console.log(`</show_result>`)
248
+ }
249
+
250
+ export const showCommand = (
251
+ changeId: string,
252
+ options: ShowOptions,
253
+ ): Effect.Effect<void, ApiError | Error, GerritApiService> =>
254
+ Effect.gen(function* () {
255
+ // Fetch all data concurrently
256
+ const [changeDetails, diff, commentsAndMessages] = yield* Effect.all(
257
+ [
258
+ getChangeDetails(changeId),
259
+ getDiffForChange(changeId),
260
+ getCommentsAndMessagesForChange(changeId),
261
+ ],
262
+ { concurrency: 'unbounded' },
263
+ )
264
+
265
+ const { comments, messages } = commentsAndMessages
266
+
267
+ // Get context for each comment using concurrent requests
268
+ const contextEffects = comments.map((comment) =>
269
+ comment.path && comment.line
270
+ ? getDiffContext(changeId, comment.path, comment.line).pipe(
271
+ Effect.map((context) => ({ comment, context })),
272
+ // Graceful degradation for diff fetch failures
273
+ Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
274
+ )
275
+ : Effect.succeed({ comment, context: undefined }),
276
+ )
277
+
278
+ // Execute all context fetches concurrently
279
+ const commentsWithContext = yield* Effect.all(contextEffects, {
280
+ concurrency: 'unbounded',
281
+ })
282
+
283
+ // Format output
284
+ if (options.xml) {
285
+ formatShowXml(changeDetails, diff, commentsWithContext, messages)
286
+ } else {
287
+ formatShowPretty(changeDetails, diff, commentsWithContext, messages)
288
+ }
289
+ }).pipe(
290
+ // Regional error boundary for the entire command
291
+ Effect.catchTag('ApiError', (error) => {
292
+ if (options.xml) {
293
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
294
+ console.log(`<show_result>`)
295
+ console.log(` <status>error</status>`)
296
+ console.log(` <error><![CDATA[${error.message}]]></error>`)
297
+ console.log(`</show_result>`)
298
+ } else {
299
+ console.error(`✗ Failed to fetch change details: ${error.message}`)
300
+ }
301
+ return Effect.succeed(undefined)
302
+ }),
303
+ )
@@ -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
+ })