@aaronshaf/ger 0.1.10 → 0.2.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.
@@ -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,5 +1,9 @@
1
1
  import { Effect, pipe, Schema, Layer } from 'effect'
2
- import { ReviewStrategyService, type ReviewStrategy } from '@/services/review-strategy'
2
+ import {
3
+ ReviewStrategyService,
4
+ type ReviewStrategy,
5
+ ReviewStrategyError,
6
+ } from '@/services/review-strategy'
3
7
  import { commentCommandWithInput } from './comment'
4
8
  import { Console } from 'effect'
5
9
  import { type ApiError, GerritApiService } from '@/api/gerrit'
@@ -314,7 +318,14 @@ const promptUser = (message: string): Effect.Effect<boolean, never> =>
314
318
  })
315
319
  })
316
320
 
317
- export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
321
+ export const reviewCommand = (
322
+ changeId: string,
323
+ options: ReviewOptions = {},
324
+ ): Effect.Effect<
325
+ void,
326
+ Error | ReviewStrategyError,
327
+ GerritApiService | ReviewStrategyService | GitWorktreeService
328
+ > =>
318
329
  Effect.gen(function* () {
319
330
  const reviewStrategy = yield* ReviewStrategyService
320
331
  const gitService = yield* GitWorktreeService
@@ -270,7 +270,7 @@ const setupEffect = (configService: ConfigServiceImpl) =>
270
270
  ),
271
271
  )
272
272
 
273
- export async function setup() {
273
+ export async function setup(): Promise<void> {
274
274
  const program = pipe(
275
275
  ConfigService,
276
276
  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
  }),