@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.
- package/.github/workflows/claude-code-review.yml +61 -56
- package/.github/workflows/claude.yml +10 -24
- package/README.md +58 -0
- package/bun.lock +8 -8
- package/package.json +3 -3
- package/src/api/gerrit.ts +54 -16
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/review.ts +13 -128
- package/src/cli/commands/setup.ts +3 -2
- package/src/cli/commands/show.ts +112 -18
- package/src/cli/index.ts +141 -24
- package/src/schemas/config.ts +13 -4
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +60 -16
- package/src/services/git-worktree.ts +73 -33
- package/src/services/review-strategy.ts +40 -22
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/review-prompt-builder.ts +0 -1
- package/src/utils/url-parser.test.ts +149 -1
- package/src/utils/url-parser.ts +27 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/extract-url.test.ts +518 -0
- package/tests/mocks/fetch-mock.ts +5 -2
- package/tests/mocks/msw-handlers.ts +3 -3
- package/tests/setup.test.ts +7 -11
- package/tests/show-auto-detect.test.ts +306 -0
- package/tests/show.test.ts +157 -1
- package/tests/unit/git-branch-detection.test.ts +1 -2
- package/tests/unit/git-worktree.test.ts +2 -1
- package/tests/unit/services/review-strategy.test.ts +2 -2
- package/tsconfig.json +2 -1
|
@@ -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
|
|
2
|
-
import { ReviewStrategyService,
|
|
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 {
|
|
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
|
|
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', (
|
|
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 = (
|
|
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
|
|
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)),
|
package/src/cli/commands/show.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
90
|
+
// Sort inline comments by date (ascending - oldest first)
|
|
90
91
|
allComments.sort((a, b) => {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
return
|
|
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 (
|
|
97
|
-
const sortedMessages =
|
|
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(
|
|
259
|
-
getDiffForChange(
|
|
260
|
-
getCommentsAndMessagesForChange(
|
|
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(
|
|
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.
|
|
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.
|
|
292
|
-
|
|
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[${
|
|
390
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
|
|
297
391
|
console.log(`</show_result>`)
|
|
298
392
|
} else {
|
|
299
|
-
console.error(`✗
|
|
393
|
+
console.error(`✗ Error: ${errorMessage}`)
|
|
300
394
|
}
|
|
301
395
|
return Effect.succeed(undefined)
|
|
302
396
|
}),
|