@aaronshaf/ger 0.1.11 → 0.2.1

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.
@@ -0,0 +1,266 @@
1
+ import { Effect, Schema } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
4
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
5
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
6
+
7
+ // Schema for validating extract-url options
8
+ const ExtractUrlOptionsSchema: Schema.Schema<
9
+ {
10
+ readonly includeComments?: boolean
11
+ readonly regex?: boolean
12
+ readonly xml?: boolean
13
+ readonly json?: boolean
14
+ },
15
+ {
16
+ readonly includeComments?: boolean
17
+ readonly regex?: boolean
18
+ readonly xml?: boolean
19
+ readonly json?: boolean
20
+ }
21
+ > = Schema.Struct({
22
+ includeComments: Schema.optional(Schema.Boolean),
23
+ regex: Schema.optional(Schema.Boolean),
24
+ xml: Schema.optional(Schema.Boolean),
25
+ json: Schema.optional(Schema.Boolean),
26
+ })
27
+
28
+ export interface ExtractUrlOptions extends Schema.Schema.Type<typeof ExtractUrlOptionsSchema> {}
29
+
30
+ // Schema for validating pattern input
31
+ const PatternSchema: Schema.Schema<string, string> = Schema.String.pipe(
32
+ Schema.minLength(1, { message: () => 'Pattern cannot be empty' }),
33
+ Schema.maxLength(500, { message: () => 'Pattern is too long (max 500 characters)' }),
34
+ )
35
+
36
+ // URL matching regex - matches http:// and https:// URLs
37
+ const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
38
+
39
+ // Regex validation error class
40
+ export class RegexValidationError extends Error {
41
+ readonly _tag = 'RegexValidationError'
42
+ constructor(message: string) {
43
+ super(message)
44
+ this.name = 'RegexValidationError'
45
+ }
46
+ }
47
+
48
+ // Safely create regex with validation and timeout protection
49
+ const createSafeRegex = (pattern: string): Effect.Effect<RegExp, RegexValidationError> =>
50
+ Effect.try({
51
+ try: () => {
52
+ // Validate regex complexity by checking for dangerous patterns
53
+ // These patterns check for nested quantifiers that can cause ReDoS
54
+ const dangerousPatterns = [
55
+ /\([^)]*[+*][^)]*\)[+*]/, // Nested quantifiers like (a+)+ or (a*)*
56
+ /\([^)]*[+*][^)]*\)[+*?]/, // Nested quantifiers with ? like (a+)+?
57
+ /\[[^\]]*\][+*]{2,}/, // Character class with multiple quantifiers like [a-z]++
58
+ ]
59
+
60
+ for (const dangerous of dangerousPatterns) {
61
+ if (dangerous.test(pattern)) {
62
+ throw new RegexValidationError(
63
+ 'Pattern contains potentially dangerous nested quantifiers that could cause performance issues',
64
+ )
65
+ }
66
+ }
67
+
68
+ // Try to create the regex - this will throw if syntax is invalid
69
+ return new RegExp(pattern)
70
+ },
71
+ catch: (error) => {
72
+ if (error instanceof RegexValidationError) {
73
+ return error
74
+ }
75
+ return new RegexValidationError(
76
+ `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`,
77
+ )
78
+ },
79
+ })
80
+
81
+ const extractUrlsFromText = (
82
+ text: string,
83
+ pattern: string,
84
+ useRegex: boolean,
85
+ ): Effect.Effect<readonly string[], RegexValidationError> =>
86
+ Effect.gen(function* () {
87
+ // First, find all URLs in the text
88
+ const urls = text.match(URL_REGEX) || []
89
+
90
+ // Filter URLs by pattern
91
+ if (useRegex) {
92
+ const regex = yield* createSafeRegex(pattern)
93
+ return urls.filter((url) => regex.test(url))
94
+ } else {
95
+ // Substring match (case-insensitive)
96
+ const lowerPattern = pattern.toLowerCase()
97
+ return urls.filter((url) => url.toLowerCase().includes(lowerPattern))
98
+ }
99
+ })
100
+
101
+ const getCommentsAndMessages = (
102
+ changeId: string,
103
+ ): Effect.Effect<
104
+ { readonly comments: readonly CommentInfo[]; readonly messages: readonly MessageInfo[] },
105
+ ApiError,
106
+ GerritApiService
107
+ > =>
108
+ Effect.gen(function* () {
109
+ const gerritApi = yield* GerritApiService
110
+
111
+ // Get both inline comments and review messages concurrently
112
+ const [comments, messages] = yield* Effect.all(
113
+ [gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
114
+ { concurrency: 'unbounded' },
115
+ )
116
+
117
+ // Flatten all inline comments from all files using functional patterns
118
+ const allComments = Object.entries(comments).flatMap(([path, fileComments]) =>
119
+ fileComments.map((comment) => ({
120
+ ...comment,
121
+ path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
122
+ })),
123
+ )
124
+
125
+ // Sort inline comments by date (ascending - oldest first)
126
+ const sortedComments = [...allComments].sort((a, b) => {
127
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
128
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
129
+ return dateA - dateB
130
+ })
131
+
132
+ // Sort messages by date (ascending - oldest first)
133
+ const sortedMessages = [...messages].sort((a, b) => {
134
+ const dateA = new Date(a.date).getTime()
135
+ const dateB = new Date(b.date).getTime()
136
+ return dateA - dateB
137
+ })
138
+
139
+ return { comments: sortedComments, messages: sortedMessages }
140
+ })
141
+
142
+ const extractUrlsFromChange = (
143
+ changeId: string,
144
+ pattern: string,
145
+ options: ExtractUrlOptions,
146
+ ): Effect.Effect<readonly string[], ApiError | RegexValidationError, GerritApiService> =>
147
+ Effect.gen(function* () {
148
+ const { comments, messages } = yield* getCommentsAndMessages(changeId)
149
+
150
+ // Extract URLs from messages using functional patterns
151
+ const messageUrls = yield* Effect.all(
152
+ messages.map((message) =>
153
+ extractUrlsFromText(message.message, pattern, options.regex || false),
154
+ ),
155
+ { concurrency: 'unbounded' },
156
+ )
157
+
158
+ // Optionally extract URLs from comments
159
+ const commentUrls = options.includeComments
160
+ ? yield* Effect.all(
161
+ comments
162
+ .filter((comment) => comment.message !== undefined)
163
+ .map((comment) =>
164
+ extractUrlsFromText(comment.message!, pattern, options.regex || false),
165
+ ),
166
+ { concurrency: 'unbounded' },
167
+ )
168
+ : []
169
+
170
+ // Flatten all URLs
171
+ return [...messageUrls.flat(), ...commentUrls.flat()]
172
+ })
173
+
174
+ const formatUrlsPretty = (urls: readonly string[]): Effect.Effect<void> =>
175
+ Effect.sync(() => {
176
+ for (const url of urls) {
177
+ console.log(url)
178
+ }
179
+ })
180
+
181
+ const formatUrlsXml = (urls: readonly string[]): Effect.Effect<void> =>
182
+ Effect.sync(() => {
183
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
184
+ console.log(`<extract_url_result>`)
185
+ console.log(` <status>success</status>`)
186
+ console.log(` <urls>`)
187
+ console.log(` <count>${urls.length}</count>`)
188
+ for (const url of urls) {
189
+ console.log(` <url>${escapeXML(url)}</url>`)
190
+ }
191
+ console.log(` </urls>`)
192
+ console.log(`</extract_url_result>`)
193
+ })
194
+
195
+ const formatUrlsJson = (urls: readonly string[]): Effect.Effect<void> =>
196
+ Effect.sync(() => {
197
+ const output = {
198
+ status: 'success',
199
+ urls,
200
+ }
201
+ console.log(JSON.stringify(output, null, 2))
202
+ })
203
+
204
+ export const extractUrlCommand = (
205
+ pattern: string,
206
+ changeId: string | undefined,
207
+ options: ExtractUrlOptions,
208
+ ): Effect.Effect<
209
+ void,
210
+ ApiError | Error | GitError | NoChangeIdError | RegexValidationError,
211
+ GerritApiService
212
+ > =>
213
+ Effect.gen(function* () {
214
+ // Validate inputs using Effect Schema
215
+ const validatedPattern = yield* Schema.decodeUnknown(PatternSchema)(pattern)
216
+ const validatedOptions = yield* Schema.decodeUnknown(ExtractUrlOptionsSchema)(options)
217
+
218
+ // Auto-detect Change-ID from HEAD commit if not provided
219
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
220
+
221
+ // Extract URLs
222
+ const urls = yield* extractUrlsFromChange(resolvedChangeId, validatedPattern, validatedOptions)
223
+
224
+ // Format output using Effect-wrapped functions
225
+ if (validatedOptions.json) {
226
+ yield* formatUrlsJson(urls)
227
+ } else if (validatedOptions.xml) {
228
+ yield* formatUrlsXml(urls)
229
+ } else {
230
+ yield* formatUrlsPretty(urls)
231
+ }
232
+ }).pipe(
233
+ // Regional error boundary for the entire command
234
+ Effect.catchAll((error) =>
235
+ Effect.sync(() => {
236
+ const errorMessage =
237
+ error instanceof GitError ||
238
+ error instanceof NoChangeIdError ||
239
+ error instanceof RegexValidationError ||
240
+ error instanceof Error
241
+ ? error.message
242
+ : String(error)
243
+
244
+ if (options.json) {
245
+ console.log(
246
+ JSON.stringify(
247
+ {
248
+ status: 'error',
249
+ error: errorMessage,
250
+ },
251
+ null,
252
+ 2,
253
+ ),
254
+ )
255
+ } else if (options.xml) {
256
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
257
+ console.log(`<extract_url_result>`)
258
+ console.log(` <status>error</status>`)
259
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
260
+ console.log(`</extract_url_result>`)
261
+ } else {
262
+ console.error(`✗ Error: ${errorMessage}`)
263
+ }
264
+ }),
265
+ ),
266
+ )
@@ -1,18 +1,8 @@
1
- import { Effect, pipe, Schema, Layer } from 'effect'
2
- import { ReviewStrategyService, type ReviewStrategy } from '@/services/review-strategy'
1
+ import { Effect, pipe, Schema } from 'effect'
2
+ import { ReviewStrategyService, ReviewStrategyError } from '@/services/review-strategy'
3
3
  import { commentCommandWithInput } from './comment'
4
4
  import { Console } from 'effect'
5
- import { type ApiError, GerritApiService } from '@/api/gerrit'
6
- import type { CommentInfo } from '@/schemas/gerrit'
7
- import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
- import { formatDiffPretty } from '@/utils/diff-formatters'
9
- import { formatDate } from '@/utils/formatters'
10
- import {
11
- formatChangeAsXML,
12
- formatCommentsAsXML,
13
- formatMessagesAsXML,
14
- flattenComments,
15
- } from '@/utils/review-formatters'
5
+ import { GerritApiService } from '@/api/gerrit'
16
6
  import { buildEnhancedPrompt } from '@/utils/review-prompt-builder'
17
7
  import * as fs from 'node:fs/promises'
18
8
  import * as fsSync from 'node:fs'
@@ -21,7 +11,7 @@ import * as path from 'node:path'
21
11
  import { fileURLToPath } from 'node:url'
22
12
  import { dirname } from 'node:path'
23
13
  import * as readline from 'node:readline'
24
- import { GitWorktreeService, GitWorktreeServiceLive } from '@/services/git-worktree'
14
+ import { GitWorktreeService } from '@/services/git-worktree'
25
15
 
26
16
  // Get the directory of this module
27
17
  const __filename = fileURLToPath(import.meta.url)
@@ -122,7 +112,7 @@ const validateAndFixInlineComments = (
122
112
  for (const rawComment of rawComments) {
123
113
  // Validate comment structure using Effect Schema
124
114
  const parseResult = yield* Schema.decodeUnknown(InlineCommentSchema)(rawComment).pipe(
125
- Effect.catchTag('ParseError', (parseError) =>
115
+ Effect.catchTag('ParseError', (_parseError) =>
126
116
  Effect.gen(function* () {
127
117
  yield* Console.warn('Skipping comment with invalid structure')
128
118
  return yield* Effect.succeed(null)
@@ -188,118 +178,6 @@ const validateAndFixInlineComments = (
188
178
  return validComments
189
179
  })
190
180
 
191
- // Legacy helper for backward compatibility (will be removed)
192
- const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
193
- Effect.gen(function* () {
194
- const gerritApi = yield* GerritApiService
195
-
196
- // Fetch all data
197
- const change = yield* gerritApi.getChange(changeId)
198
- const diffResult = yield* gerritApi.getDiff(changeId)
199
- const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
200
- const commentsMap = yield* gerritApi.getComments(changeId)
201
- const messages = yield* gerritApi.getMessages(changeId)
202
-
203
- const comments = flattenComments(commentsMap)
204
-
205
- // Build XML string using helper functions
206
- const xmlLines: string[] = []
207
- xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
208
- xmlLines.push(`<show_result>`)
209
- xmlLines.push(` <status>success</status>`)
210
- xmlLines.push(...formatChangeAsXML(change))
211
- xmlLines.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
212
- xmlLines.push(...formatCommentsAsXML(comments))
213
- xmlLines.push(...formatMessagesAsXML(messages))
214
- xmlLines.push(`</show_result>`)
215
-
216
- return xmlLines.join('\n')
217
- })
218
-
219
- // Helper to get change data and format as pretty string
220
- const getChangeDataAsPretty = (
221
- changeId: string,
222
- ): Effect.Effect<string, ApiError, GerritApiService> =>
223
- Effect.gen(function* () {
224
- const gerritApi = yield* GerritApiService
225
-
226
- // Fetch all data
227
- const change = yield* gerritApi.getChange(changeId)
228
- const diffResult = yield* gerritApi.getDiff(changeId)
229
- const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
230
- const commentsMap = yield* gerritApi.getComments(changeId)
231
- const messages = yield* gerritApi.getMessages(changeId)
232
-
233
- const comments = flattenComments(commentsMap)
234
-
235
- // Build pretty string
236
- const lines: string[] = []
237
-
238
- // Change details header
239
- lines.push('━'.repeat(80))
240
- lines.push(`📋 Change ${change._number}: ${change.subject}`)
241
- lines.push('━'.repeat(80))
242
- lines.push('')
243
-
244
- // Metadata
245
- lines.push('📝 Details:')
246
- lines.push(` Project: ${change.project}`)
247
- lines.push(` Branch: ${change.branch}`)
248
- lines.push(` Status: ${change.status}`)
249
- lines.push(` Owner: ${change.owner?.name || change.owner?.email || 'Unknown'}`)
250
- lines.push(` Created: ${change.created ? formatDate(change.created) : 'Unknown'}`)
251
- lines.push(` Updated: ${change.updated ? formatDate(change.updated) : 'Unknown'}`)
252
- lines.push(` Change-Id: ${change.change_id}`)
253
- lines.push('')
254
-
255
- // Diff section
256
- lines.push('🔍 Diff:')
257
- lines.push('─'.repeat(40))
258
- lines.push(formatDiffPretty(diff))
259
- lines.push('')
260
-
261
- // Comments section
262
- if (comments.length > 0) {
263
- lines.push('💬 Inline Comments:')
264
- lines.push('─'.repeat(40))
265
- for (const comment of comments) {
266
- const author = comment.author?.name || 'Unknown'
267
- const date = comment.updated ? formatDate(comment.updated) : 'Unknown'
268
- lines.push(`📅 ${date} - ${author}`)
269
- if (comment.path) lines.push(` File: ${comment.path}`)
270
- if (comment.line) lines.push(` Line: ${comment.line}`)
271
- lines.push(` ${comment.message}`)
272
- if (comment.unresolved) lines.push(` ⚠️ Unresolved`)
273
- lines.push('')
274
- }
275
- }
276
-
277
- // Messages section
278
- if (messages.length > 0) {
279
- lines.push('📝 Review Activity:')
280
- lines.push('─'.repeat(40))
281
- for (const message of messages) {
282
- const author = message.author?.name || 'Unknown'
283
- const date = formatDate(message.date)
284
- const cleanMessage = message.message.trim()
285
-
286
- // Skip very short automated messages
287
- if (
288
- cleanMessage.length < 10 &&
289
- (cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
290
- ) {
291
- continue
292
- }
293
-
294
- lines.push(`📅 ${date} - ${author}`)
295
- lines.push(` ${cleanMessage}`)
296
- lines.push('')
297
- }
298
- }
299
-
300
- return lines.join('\n')
301
- })
302
-
303
181
  // Helper function to prompt user for confirmation
304
182
  const promptUser = (message: string): Effect.Effect<boolean, never> =>
305
183
  Effect.async<boolean, never>((resume) => {
@@ -314,7 +192,14 @@ const promptUser = (message: string): Effect.Effect<boolean, never> =>
314
192
  })
315
193
  })
316
194
 
317
- export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
195
+ export const reviewCommand = (
196
+ changeId: string,
197
+ options: ReviewOptions = {},
198
+ ): Effect.Effect<
199
+ void,
200
+ Error | ReviewStrategyError,
201
+ GerritApiService | ReviewStrategyService | GitWorktreeService
202
+ > =>
318
203
  Effect.gen(function* () {
319
204
  const reviewStrategy = yield* ReviewStrategyService
320
205
  const gitService = yield* GitWorktreeService
@@ -11,6 +11,7 @@ import { AppConfig } from '@/schemas/config'
11
11
  import { Schema } from '@effect/schema'
12
12
  import { input, password } from '@inquirer/prompts'
13
13
  import { spawn } from 'node:child_process'
14
+ import { normalizeGerritHost } from '@/utils/url-parser'
14
15
 
15
16
  // Check if a command exists on the system
16
17
  const checkCommandExists = (command: string): Promise<boolean> =>
@@ -209,7 +210,7 @@ const setupEffect = (configService: ConfigServiceImpl) =>
209
210
 
210
211
  // Build flat config
211
212
  const configData = {
212
- host: host.trim().replace(/\/$/, ''), // Remove trailing slash
213
+ host: normalizeGerritHost(host),
213
214
  username: username.trim(),
214
215
  password: passwordValue,
215
216
  ...(aiToolCommand && {
@@ -270,7 +271,7 @@ const setupEffect = (configService: ConfigServiceImpl) =>
270
271
  ),
271
272
  )
272
273
 
273
- export async function setup() {
274
+ export async function setup(): Promise<void> {
274
275
  const program = pipe(
275
276
  ConfigService,
276
277
  Effect.flatMap((configService) => setupEffect(configService)),
@@ -6,10 +6,11 @@ import { getDiffContext } from '@/utils/diff-context'
6
6
  import { formatDiffPretty } from '@/utils/diff-formatters'
7
7
  import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
8
  import { formatDate } from '@/utils/formatters'
9
- import { sortMessagesByDate } from '@/utils/message-filters'
9
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
10
 
11
11
  interface ShowOptions {
12
12
  xml?: boolean
13
+ json?: boolean
13
14
  }
14
15
 
15
16
  interface ChangeDetails {
@@ -86,15 +87,19 @@ const getCommentsAndMessagesForChange = (
86
87
  }
87
88
  }
88
89
 
89
- // Sort inline comments by path and then by line number
90
+ // Sort inline comments by date (ascending - oldest first)
90
91
  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)
92
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
93
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
94
+ return dateA - dateB
94
95
  })
95
96
 
96
- // Sort messages by date (newest first)
97
- const sortedMessages = sortMessagesByDate(messages)
97
+ // Sort messages by date (ascending - oldest first)
98
+ const sortedMessages = [...messages].sort((a, b) => {
99
+ const dateA = new Date(a.date).getTime()
100
+ const dateB = new Date(b.date).getTime()
101
+ return dateA - dateB
102
+ })
98
103
 
99
104
  return { comments: allComments, messages: sortedMessages }
100
105
  })
@@ -172,6 +177,74 @@ const formatShowPretty = (
172
177
  }
173
178
  }
174
179
 
180
+ // Helper to remove undefined values from objects
181
+ const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => {
182
+ return Object.fromEntries(
183
+ Object.entries(obj).filter(([_, value]) => value !== undefined),
184
+ ) as Partial<T>
185
+ }
186
+
187
+ const formatShowJson = (
188
+ changeDetails: ChangeDetails,
189
+ diff: string,
190
+ commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
191
+ messages: MessageInfo[],
192
+ ): void => {
193
+ const output = {
194
+ status: 'success',
195
+ change: removeUndefined({
196
+ id: changeDetails.id,
197
+ number: changeDetails.number,
198
+ subject: changeDetails.subject,
199
+ status: changeDetails.status,
200
+ project: changeDetails.project,
201
+ branch: changeDetails.branch,
202
+ owner: removeUndefined(changeDetails.owner),
203
+ created: changeDetails.created,
204
+ updated: changeDetails.updated,
205
+ }),
206
+ diff,
207
+ comments: commentsWithContext.map(({ comment, context }) =>
208
+ removeUndefined({
209
+ id: comment.id,
210
+ path: comment.path,
211
+ line: comment.line,
212
+ range: comment.range,
213
+ author: comment.author
214
+ ? removeUndefined({
215
+ name: comment.author.name,
216
+ email: comment.author.email,
217
+ account_id: comment.author._account_id,
218
+ })
219
+ : undefined,
220
+ updated: comment.updated,
221
+ message: comment.message,
222
+ unresolved: comment.unresolved,
223
+ in_reply_to: comment.in_reply_to,
224
+ context,
225
+ }),
226
+ ),
227
+ messages: messages.map((message) =>
228
+ removeUndefined({
229
+ id: message.id,
230
+ author: message.author
231
+ ? removeUndefined({
232
+ name: message.author.name,
233
+ email: message.author.email,
234
+ account_id: message.author._account_id,
235
+ })
236
+ : undefined,
237
+ date: message.date,
238
+ message: message.message,
239
+ revision: message._revision_number,
240
+ tag: message.tag,
241
+ }),
242
+ ),
243
+ }
244
+
245
+ console.log(JSON.stringify(output, null, 2))
246
+ }
247
+
175
248
  const formatShowXml = (
176
249
  changeDetails: ChangeDetails,
177
250
  diff: string,
@@ -248,16 +321,19 @@ const formatShowXml = (
248
321
  }
249
322
 
250
323
  export const showCommand = (
251
- changeId: string,
324
+ changeId: string | undefined,
252
325
  options: ShowOptions,
253
- ): Effect.Effect<void, ApiError | Error, GerritApiService> =>
326
+ ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
254
327
  Effect.gen(function* () {
328
+ // Auto-detect Change-ID from HEAD commit if not provided
329
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
330
+
255
331
  // Fetch all data concurrently
256
332
  const [changeDetails, diff, commentsAndMessages] = yield* Effect.all(
257
333
  [
258
- getChangeDetails(changeId),
259
- getDiffForChange(changeId),
260
- getCommentsAndMessagesForChange(changeId),
334
+ getChangeDetails(resolvedChangeId),
335
+ getDiffForChange(resolvedChangeId),
336
+ getCommentsAndMessagesForChange(resolvedChangeId),
261
337
  ],
262
338
  { concurrency: 'unbounded' },
263
339
  )
@@ -267,7 +343,7 @@ export const showCommand = (
267
343
  // Get context for each comment using concurrent requests
268
344
  const contextEffects = comments.map((comment) =>
269
345
  comment.path && comment.line
270
- ? getDiffContext(changeId, comment.path, comment.line).pipe(
346
+ ? getDiffContext(resolvedChangeId, comment.path, comment.line).pipe(
271
347
  Effect.map((context) => ({ comment, context })),
272
348
  // Graceful degradation for diff fetch failures
273
349
  Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
@@ -281,22 +357,40 @@ export const showCommand = (
281
357
  })
282
358
 
283
359
  // Format output
284
- if (options.xml) {
360
+ if (options.json) {
361
+ formatShowJson(changeDetails, diff, commentsWithContext, messages)
362
+ } else if (options.xml) {
285
363
  formatShowXml(changeDetails, diff, commentsWithContext, messages)
286
364
  } else {
287
365
  formatShowPretty(changeDetails, diff, commentsWithContext, messages)
288
366
  }
289
367
  }).pipe(
290
368
  // Regional error boundary for the entire command
291
- Effect.catchTag('ApiError', (error) => {
292
- if (options.xml) {
369
+ Effect.catchAll((error) => {
370
+ const errorMessage =
371
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
372
+ ? error.message
373
+ : String(error)
374
+
375
+ if (options.json) {
376
+ console.log(
377
+ JSON.stringify(
378
+ {
379
+ status: 'error',
380
+ error: errorMessage,
381
+ },
382
+ null,
383
+ 2,
384
+ ),
385
+ )
386
+ } else if (options.xml) {
293
387
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
294
388
  console.log(`<show_result>`)
295
389
  console.log(` <status>error</status>`)
296
- console.log(` <error><![CDATA[${error.message}]]></error>`)
390
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
297
391
  console.log(`</show_result>`)
298
392
  } else {
299
- console.error(`✗ Failed to fetch change details: ${error.message}`)
393
+ console.error(`✗ Error: ${errorMessage}`)
300
394
  }
301
395
  return Effect.succeed(undefined)
302
396
  }),