@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { Schema } from '@effect/schema'
|
|
2
|
+
|
|
3
|
+
// Authentication schemas
|
|
4
|
+
export const GerritCredentials: Schema.Schema<{
|
|
5
|
+
readonly host: string
|
|
6
|
+
readonly username: string
|
|
7
|
+
readonly password: string
|
|
8
|
+
}> = Schema.Struct({
|
|
9
|
+
host: Schema.String.pipe(
|
|
10
|
+
Schema.pattern(/^https?:\/\/.+$/),
|
|
11
|
+
Schema.annotations({ description: 'Gerrit server URL' }),
|
|
12
|
+
),
|
|
13
|
+
username: Schema.String.pipe(
|
|
14
|
+
Schema.minLength(1),
|
|
15
|
+
Schema.annotations({ description: 'Gerrit username' }),
|
|
16
|
+
),
|
|
17
|
+
password: Schema.String.pipe(
|
|
18
|
+
Schema.minLength(1),
|
|
19
|
+
Schema.annotations({ description: 'HTTP password or API token' }),
|
|
20
|
+
),
|
|
21
|
+
})
|
|
22
|
+
export type GerritCredentials = Schema.Schema.Type<typeof GerritCredentials>
|
|
23
|
+
|
|
24
|
+
// Change schemas
|
|
25
|
+
export const ChangeInfo: Schema.Schema<{
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly project: string
|
|
28
|
+
readonly branch: string
|
|
29
|
+
readonly change_id: string
|
|
30
|
+
readonly subject: string
|
|
31
|
+
readonly status: 'NEW' | 'MERGED' | 'ABANDONED' | 'DRAFT'
|
|
32
|
+
readonly created?: string
|
|
33
|
+
readonly updated?: string
|
|
34
|
+
readonly insertions?: number
|
|
35
|
+
readonly deletions?: number
|
|
36
|
+
readonly _number: number
|
|
37
|
+
readonly owner?: {
|
|
38
|
+
readonly _account_id: number
|
|
39
|
+
readonly name?: string
|
|
40
|
+
readonly email?: string
|
|
41
|
+
readonly username?: string
|
|
42
|
+
}
|
|
43
|
+
readonly labels?: Record<
|
|
44
|
+
string,
|
|
45
|
+
{
|
|
46
|
+
readonly approved?: {
|
|
47
|
+
readonly _account_id: number
|
|
48
|
+
readonly name?: string
|
|
49
|
+
readonly email?: string
|
|
50
|
+
readonly username?: string
|
|
51
|
+
}
|
|
52
|
+
readonly rejected?: {
|
|
53
|
+
readonly _account_id: number
|
|
54
|
+
readonly name?: string
|
|
55
|
+
readonly email?: string
|
|
56
|
+
readonly username?: string
|
|
57
|
+
}
|
|
58
|
+
readonly recommended?: {
|
|
59
|
+
readonly _account_id: number
|
|
60
|
+
readonly name?: string
|
|
61
|
+
readonly email?: string
|
|
62
|
+
readonly username?: string
|
|
63
|
+
}
|
|
64
|
+
readonly disliked?: {
|
|
65
|
+
readonly _account_id: number
|
|
66
|
+
readonly name?: string
|
|
67
|
+
readonly email?: string
|
|
68
|
+
readonly username?: string
|
|
69
|
+
}
|
|
70
|
+
readonly value?: number
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
readonly submittable?: boolean
|
|
74
|
+
readonly work_in_progress?: boolean
|
|
75
|
+
}> = Schema.Struct({
|
|
76
|
+
id: Schema.String,
|
|
77
|
+
project: Schema.String,
|
|
78
|
+
branch: Schema.String,
|
|
79
|
+
change_id: Schema.String,
|
|
80
|
+
subject: Schema.String,
|
|
81
|
+
status: Schema.Literal('NEW', 'MERGED', 'ABANDONED', 'DRAFT'),
|
|
82
|
+
created: Schema.optional(Schema.String),
|
|
83
|
+
updated: Schema.optional(Schema.String),
|
|
84
|
+
insertions: Schema.optional(Schema.Number),
|
|
85
|
+
deletions: Schema.optional(Schema.Number),
|
|
86
|
+
_number: Schema.Number,
|
|
87
|
+
owner: Schema.optional(
|
|
88
|
+
Schema.Struct({
|
|
89
|
+
_account_id: Schema.Number,
|
|
90
|
+
name: Schema.optional(Schema.String),
|
|
91
|
+
email: Schema.optional(Schema.String),
|
|
92
|
+
username: Schema.optional(Schema.String),
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
labels: Schema.optional(
|
|
96
|
+
Schema.Record({
|
|
97
|
+
key: Schema.String,
|
|
98
|
+
value: Schema.Struct({
|
|
99
|
+
approved: Schema.optional(
|
|
100
|
+
Schema.Struct({
|
|
101
|
+
_account_id: Schema.Number,
|
|
102
|
+
name: Schema.optional(Schema.String),
|
|
103
|
+
email: Schema.optional(Schema.String),
|
|
104
|
+
username: Schema.optional(Schema.String),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
rejected: Schema.optional(
|
|
108
|
+
Schema.Struct({
|
|
109
|
+
_account_id: Schema.Number,
|
|
110
|
+
name: Schema.optional(Schema.String),
|
|
111
|
+
email: Schema.optional(Schema.String),
|
|
112
|
+
username: Schema.optional(Schema.String),
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
recommended: Schema.optional(
|
|
116
|
+
Schema.Struct({
|
|
117
|
+
_account_id: Schema.Number,
|
|
118
|
+
name: Schema.optional(Schema.String),
|
|
119
|
+
email: Schema.optional(Schema.String),
|
|
120
|
+
username: Schema.optional(Schema.String),
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
disliked: Schema.optional(
|
|
124
|
+
Schema.Struct({
|
|
125
|
+
_account_id: Schema.Number,
|
|
126
|
+
name: Schema.optional(Schema.String),
|
|
127
|
+
email: Schema.optional(Schema.String),
|
|
128
|
+
username: Schema.optional(Schema.String),
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
value: Schema.optional(Schema.Number),
|
|
132
|
+
}),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
submittable: Schema.optional(Schema.Boolean),
|
|
136
|
+
work_in_progress: Schema.optional(Schema.Boolean),
|
|
137
|
+
})
|
|
138
|
+
export type ChangeInfo = Schema.Schema.Type<typeof ChangeInfo>
|
|
139
|
+
|
|
140
|
+
// Comment schemas
|
|
141
|
+
export const CommentInput: Schema.Schema<{
|
|
142
|
+
readonly message: string
|
|
143
|
+
readonly unresolved?: boolean
|
|
144
|
+
}> = Schema.Struct({
|
|
145
|
+
message: Schema.String.pipe(
|
|
146
|
+
Schema.minLength(1),
|
|
147
|
+
Schema.annotations({ description: 'Comment message' }),
|
|
148
|
+
),
|
|
149
|
+
unresolved: Schema.optional(Schema.Boolean),
|
|
150
|
+
})
|
|
151
|
+
export type CommentInput = Schema.Schema.Type<typeof CommentInput>
|
|
152
|
+
|
|
153
|
+
// Comment info returned from API
|
|
154
|
+
export const CommentInfo: Schema.Schema<{
|
|
155
|
+
readonly id: string
|
|
156
|
+
readonly path?: string
|
|
157
|
+
readonly line?: number
|
|
158
|
+
readonly range?: {
|
|
159
|
+
readonly start_line: number
|
|
160
|
+
readonly end_line: number
|
|
161
|
+
readonly start_character?: number
|
|
162
|
+
readonly end_character?: number
|
|
163
|
+
}
|
|
164
|
+
readonly message: string
|
|
165
|
+
readonly author?: {
|
|
166
|
+
readonly name?: string
|
|
167
|
+
readonly email?: string
|
|
168
|
+
readonly _account_id?: number
|
|
169
|
+
}
|
|
170
|
+
readonly updated?: string
|
|
171
|
+
readonly unresolved?: boolean
|
|
172
|
+
readonly in_reply_to?: string
|
|
173
|
+
}> = Schema.Struct({
|
|
174
|
+
id: Schema.String,
|
|
175
|
+
path: Schema.optional(Schema.String),
|
|
176
|
+
line: Schema.optional(Schema.Number),
|
|
177
|
+
range: Schema.optional(
|
|
178
|
+
Schema.Struct({
|
|
179
|
+
start_line: Schema.Number,
|
|
180
|
+
end_line: Schema.Number,
|
|
181
|
+
start_character: Schema.optional(Schema.Number),
|
|
182
|
+
end_character: Schema.optional(Schema.Number),
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
message: Schema.String,
|
|
186
|
+
author: Schema.optional(
|
|
187
|
+
Schema.Struct({
|
|
188
|
+
name: Schema.optional(Schema.String),
|
|
189
|
+
email: Schema.optional(Schema.String),
|
|
190
|
+
_account_id: Schema.optional(Schema.Number),
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
updated: Schema.optional(Schema.String),
|
|
194
|
+
unresolved: Schema.optional(Schema.Boolean),
|
|
195
|
+
in_reply_to: Schema.optional(Schema.String),
|
|
196
|
+
})
|
|
197
|
+
export type CommentInfo = Schema.Schema.Type<typeof CommentInfo>
|
|
198
|
+
|
|
199
|
+
// Message info for review messages
|
|
200
|
+
export const MessageInfo: Schema.Schema<{
|
|
201
|
+
readonly id: string
|
|
202
|
+
readonly message: string
|
|
203
|
+
readonly author?: {
|
|
204
|
+
readonly _account_id: number
|
|
205
|
+
readonly name?: string
|
|
206
|
+
readonly email?: string
|
|
207
|
+
}
|
|
208
|
+
readonly date: string
|
|
209
|
+
readonly _revision_number?: number
|
|
210
|
+
readonly tag?: string
|
|
211
|
+
}> = Schema.Struct({
|
|
212
|
+
id: Schema.String,
|
|
213
|
+
message: Schema.String,
|
|
214
|
+
author: Schema.optional(
|
|
215
|
+
Schema.Struct({
|
|
216
|
+
_account_id: Schema.Number,
|
|
217
|
+
name: Schema.optional(Schema.String),
|
|
218
|
+
email: Schema.optional(Schema.String),
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
date: Schema.String,
|
|
222
|
+
_revision_number: Schema.optional(Schema.Number),
|
|
223
|
+
tag: Schema.optional(Schema.String),
|
|
224
|
+
})
|
|
225
|
+
export type MessageInfo = Schema.Schema.Type<typeof MessageInfo>
|
|
226
|
+
|
|
227
|
+
export const ReviewInput: Schema.Schema<{
|
|
228
|
+
readonly message?: string
|
|
229
|
+
readonly labels?: Record<string, number>
|
|
230
|
+
readonly comments?: Record<
|
|
231
|
+
string,
|
|
232
|
+
ReadonlyArray<{
|
|
233
|
+
readonly line?: number
|
|
234
|
+
readonly range?: {
|
|
235
|
+
readonly start_line: number
|
|
236
|
+
readonly end_line: number
|
|
237
|
+
readonly start_character?: number
|
|
238
|
+
readonly end_character?: number
|
|
239
|
+
}
|
|
240
|
+
readonly message: string
|
|
241
|
+
readonly side?: 'PARENT' | 'REVISION'
|
|
242
|
+
readonly unresolved?: boolean
|
|
243
|
+
}>
|
|
244
|
+
>
|
|
245
|
+
}> = Schema.Struct({
|
|
246
|
+
message: Schema.optional(Schema.String),
|
|
247
|
+
labels: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Number })),
|
|
248
|
+
comments: Schema.optional(
|
|
249
|
+
Schema.Record({
|
|
250
|
+
key: Schema.String,
|
|
251
|
+
value: Schema.Array(
|
|
252
|
+
Schema.Struct({
|
|
253
|
+
line: Schema.optional(Schema.Number),
|
|
254
|
+
range: Schema.optional(
|
|
255
|
+
Schema.Struct({
|
|
256
|
+
start_line: Schema.Number,
|
|
257
|
+
end_line: Schema.Number,
|
|
258
|
+
start_character: Schema.optional(Schema.Number),
|
|
259
|
+
end_character: Schema.optional(Schema.Number),
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
message: Schema.String,
|
|
263
|
+
side: Schema.optional(Schema.Literal('PARENT', 'REVISION')),
|
|
264
|
+
unresolved: Schema.optional(Schema.Boolean),
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
})
|
|
270
|
+
export type ReviewInput = Schema.Schema.Type<typeof ReviewInput>
|
|
271
|
+
|
|
272
|
+
// File and diff schemas
|
|
273
|
+
export const FileInfo: Schema.Schema<{
|
|
274
|
+
readonly status?: 'A' | 'D' | 'R' | 'C' | 'M'
|
|
275
|
+
readonly lines_inserted?: number
|
|
276
|
+
readonly lines_deleted?: number
|
|
277
|
+
readonly size?: number
|
|
278
|
+
readonly size_delta?: number
|
|
279
|
+
readonly old_path?: string
|
|
280
|
+
}> = Schema.Struct({
|
|
281
|
+
status: Schema.optional(Schema.Literal('A', 'D', 'R', 'C', 'M')), // Added, Deleted, Renamed, Copied, Modified
|
|
282
|
+
lines_inserted: Schema.optional(Schema.Number),
|
|
283
|
+
lines_deleted: Schema.optional(Schema.Number),
|
|
284
|
+
size_delta: Schema.optional(Schema.Number),
|
|
285
|
+
size: Schema.optional(Schema.Number),
|
|
286
|
+
old_path: Schema.optional(Schema.String),
|
|
287
|
+
})
|
|
288
|
+
export type FileInfo = Schema.Schema.Type<typeof FileInfo>
|
|
289
|
+
|
|
290
|
+
export const FileDiffContent: Schema.Schema<{
|
|
291
|
+
readonly a?: string
|
|
292
|
+
readonly b?: string
|
|
293
|
+
readonly content: ReadonlyArray<{
|
|
294
|
+
readonly a?: ReadonlyArray<string>
|
|
295
|
+
readonly b?: ReadonlyArray<string>
|
|
296
|
+
readonly ab?: ReadonlyArray<string>
|
|
297
|
+
readonly edit_list?: ReadonlyArray<{
|
|
298
|
+
readonly op: 'i' | 'd' | 'r'
|
|
299
|
+
readonly a: ReadonlyArray<string>
|
|
300
|
+
readonly b: ReadonlyArray<string>
|
|
301
|
+
}>
|
|
302
|
+
readonly due_to_rebase?: boolean
|
|
303
|
+
readonly skip?: number
|
|
304
|
+
}>
|
|
305
|
+
readonly change_type?: 'ADDED' | 'MODIFIED' | 'DELETED' | 'RENAMED' | 'COPIED'
|
|
306
|
+
readonly diff_header?: ReadonlyArray<string>
|
|
307
|
+
}> = Schema.Struct({
|
|
308
|
+
a: Schema.optional(Schema.String), // Old file content path
|
|
309
|
+
b: Schema.optional(Schema.String), // New file content path
|
|
310
|
+
content: Schema.Array(
|
|
311
|
+
Schema.Struct({
|
|
312
|
+
a: Schema.optional(Schema.Array(Schema.String)), // Lines from old file
|
|
313
|
+
b: Schema.optional(Schema.Array(Schema.String)), // Lines from new file
|
|
314
|
+
ab: Schema.optional(Schema.Array(Schema.String)), // Common lines
|
|
315
|
+
edit_list: Schema.optional(
|
|
316
|
+
Schema.Array(
|
|
317
|
+
Schema.Struct({
|
|
318
|
+
op: Schema.Literal('i', 'd', 'r'), // insert, delete, replace
|
|
319
|
+
a: Schema.Array(Schema.String),
|
|
320
|
+
b: Schema.Array(Schema.String),
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
due_to_rebase: Schema.optional(Schema.Boolean),
|
|
325
|
+
skip: Schema.optional(Schema.Number),
|
|
326
|
+
}),
|
|
327
|
+
),
|
|
328
|
+
change_type: Schema.optional(Schema.Literal('ADDED', 'MODIFIED', 'DELETED', 'RENAMED', 'COPIED')),
|
|
329
|
+
diff_header: Schema.optional(Schema.Array(Schema.String)),
|
|
330
|
+
})
|
|
331
|
+
export type FileDiffContent = Schema.Schema.Type<typeof FileDiffContent>
|
|
332
|
+
|
|
333
|
+
export const RevisionInfo: Schema.Schema<{
|
|
334
|
+
readonly kind?: string
|
|
335
|
+
readonly _number: number
|
|
336
|
+
readonly created: string
|
|
337
|
+
readonly uploader: {
|
|
338
|
+
readonly _account_id: number
|
|
339
|
+
readonly name?: string
|
|
340
|
+
readonly email?: string
|
|
341
|
+
}
|
|
342
|
+
readonly ref: string
|
|
343
|
+
readonly fetch?: Record<string, unknown>
|
|
344
|
+
readonly commit?: {
|
|
345
|
+
readonly commit: string
|
|
346
|
+
readonly parents: ReadonlyArray<{
|
|
347
|
+
readonly commit: string
|
|
348
|
+
readonly subject: string
|
|
349
|
+
}>
|
|
350
|
+
readonly author: {
|
|
351
|
+
readonly name: string
|
|
352
|
+
readonly email: string
|
|
353
|
+
readonly date: string
|
|
354
|
+
}
|
|
355
|
+
readonly committer: {
|
|
356
|
+
readonly name: string
|
|
357
|
+
readonly email: string
|
|
358
|
+
readonly date: string
|
|
359
|
+
}
|
|
360
|
+
readonly subject: string
|
|
361
|
+
readonly message: string
|
|
362
|
+
}
|
|
363
|
+
}> = Schema.Struct({
|
|
364
|
+
kind: Schema.optional(Schema.String),
|
|
365
|
+
_number: Schema.Number,
|
|
366
|
+
created: Schema.String,
|
|
367
|
+
uploader: Schema.Struct({
|
|
368
|
+
_account_id: Schema.Number,
|
|
369
|
+
name: Schema.optional(Schema.String),
|
|
370
|
+
email: Schema.optional(Schema.String),
|
|
371
|
+
}),
|
|
372
|
+
ref: Schema.String,
|
|
373
|
+
fetch: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
|
|
374
|
+
commit: Schema.optional(
|
|
375
|
+
Schema.Struct({
|
|
376
|
+
commit: Schema.String,
|
|
377
|
+
parents: Schema.Array(
|
|
378
|
+
Schema.Struct({
|
|
379
|
+
commit: Schema.String,
|
|
380
|
+
subject: Schema.String,
|
|
381
|
+
}),
|
|
382
|
+
),
|
|
383
|
+
author: Schema.Struct({
|
|
384
|
+
name: Schema.String,
|
|
385
|
+
email: Schema.String,
|
|
386
|
+
date: Schema.String,
|
|
387
|
+
}),
|
|
388
|
+
committer: Schema.Struct({
|
|
389
|
+
name: Schema.String,
|
|
390
|
+
email: Schema.String,
|
|
391
|
+
date: Schema.String,
|
|
392
|
+
}),
|
|
393
|
+
subject: Schema.String,
|
|
394
|
+
message: Schema.String,
|
|
395
|
+
}),
|
|
396
|
+
),
|
|
397
|
+
files: Schema.optional(Schema.Record({ key: Schema.String, value: FileInfo })),
|
|
398
|
+
})
|
|
399
|
+
export type RevisionInfo = Schema.Schema.Type<typeof RevisionInfo>
|
|
400
|
+
|
|
401
|
+
// Diff output format options
|
|
402
|
+
export const DiffFormat: Schema.Schema<'unified' | 'json' | 'files'> = Schema.Literal(
|
|
403
|
+
'unified',
|
|
404
|
+
'json',
|
|
405
|
+
'files',
|
|
406
|
+
)
|
|
407
|
+
export type DiffFormat = Schema.Schema.Type<typeof DiffFormat>
|
|
408
|
+
|
|
409
|
+
export const DiffOptions: Schema.Schema<{
|
|
410
|
+
readonly format?: 'unified' | 'json' | 'files'
|
|
411
|
+
readonly patchset?: number
|
|
412
|
+
readonly file?: string
|
|
413
|
+
readonly filesOnly?: boolean
|
|
414
|
+
readonly fullFiles?: boolean
|
|
415
|
+
readonly base?: number
|
|
416
|
+
readonly target?: number
|
|
417
|
+
}> = Schema.Struct({
|
|
418
|
+
format: Schema.optional(DiffFormat),
|
|
419
|
+
patchset: Schema.optional(Schema.Number),
|
|
420
|
+
file: Schema.optional(Schema.String),
|
|
421
|
+
filesOnly: Schema.optional(Schema.Boolean),
|
|
422
|
+
fullFiles: Schema.optional(Schema.Boolean),
|
|
423
|
+
base: Schema.optional(Schema.Number),
|
|
424
|
+
target: Schema.optional(Schema.Number),
|
|
425
|
+
})
|
|
426
|
+
export type DiffOptions = Schema.Schema.Type<typeof DiffOptions>
|
|
427
|
+
|
|
428
|
+
// Command options schemas
|
|
429
|
+
export const DiffCommandOptions: Schema.Schema<{
|
|
430
|
+
readonly xml?: boolean
|
|
431
|
+
readonly file?: string
|
|
432
|
+
readonly filesOnly?: boolean
|
|
433
|
+
readonly format?: 'unified' | 'json' | 'files'
|
|
434
|
+
}> = Schema.Struct({
|
|
435
|
+
xml: Schema.optional(Schema.Boolean),
|
|
436
|
+
file: Schema.optional(
|
|
437
|
+
Schema.String.pipe(
|
|
438
|
+
Schema.minLength(1),
|
|
439
|
+
Schema.annotations({ description: 'File path for diff (relative to repo root)' }),
|
|
440
|
+
),
|
|
441
|
+
),
|
|
442
|
+
filesOnly: Schema.optional(Schema.Boolean),
|
|
443
|
+
format: Schema.optional(DiffFormat),
|
|
444
|
+
})
|
|
445
|
+
export type DiffCommandOptions = Schema.Schema.Type<typeof DiffCommandOptions>
|
|
446
|
+
|
|
447
|
+
// API Response schemas
|
|
448
|
+
export const GerritError: Schema.Schema<{
|
|
449
|
+
readonly message: string
|
|
450
|
+
readonly status?: number
|
|
451
|
+
}> = Schema.Struct({
|
|
452
|
+
message: Schema.String,
|
|
453
|
+
status: Schema.optional(Schema.Number),
|
|
454
|
+
})
|
|
455
|
+
export type GerritError = Schema.Schema.Type<typeof GerritError>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Effect, Layer } from 'effect'
|
|
2
|
+
import { AiService, AiServiceError, NoAiToolFoundError, AiResponseParseError } from './ai'
|
|
3
|
+
import { ConfigService, ConfigServiceLive } from './config'
|
|
4
|
+
import { exec } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
// Enhanced AI Service that uses configuration
|
|
10
|
+
export const AiServiceEnhanced = Layer.effect(
|
|
11
|
+
AiService,
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const configService = yield* ConfigService
|
|
14
|
+
|
|
15
|
+
const detectAiTool = () =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
// First check configured preference
|
|
18
|
+
const aiConfig = yield* configService.getAiConfig.pipe(
|
|
19
|
+
Effect.orElseSucceed(() => ({ autoDetect: true })),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if ('tool' in aiConfig && aiConfig.tool && !aiConfig.autoDetect) {
|
|
23
|
+
// Check if configured tool is available
|
|
24
|
+
const result = yield* Effect.tryPromise({
|
|
25
|
+
try: () => execAsync(`which ${aiConfig.tool}`),
|
|
26
|
+
catch: () => null,
|
|
27
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
28
|
+
|
|
29
|
+
if (result && result.stdout.trim()) {
|
|
30
|
+
return aiConfig.tool
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Configured tool not available, fall back to auto-detect
|
|
34
|
+
yield* Effect.logWarning(
|
|
35
|
+
`Configured AI tool '${aiConfig.tool}' not found, auto-detecting...`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Auto-detect available tools
|
|
40
|
+
const tools =
|
|
41
|
+
'tool' in aiConfig && aiConfig.tool
|
|
42
|
+
? [aiConfig.tool, 'claude', 'llm', 'opencode', 'gemini'].filter(
|
|
43
|
+
(v, i, a) => a.indexOf(v) === i,
|
|
44
|
+
)
|
|
45
|
+
: ['claude', 'llm', 'opencode', 'gemini']
|
|
46
|
+
|
|
47
|
+
for (const tool of tools) {
|
|
48
|
+
const result = yield* Effect.tryPromise({
|
|
49
|
+
try: () => execAsync(`which ${tool}`),
|
|
50
|
+
catch: () => null,
|
|
51
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
52
|
+
|
|
53
|
+
if (result && result.stdout.trim()) {
|
|
54
|
+
return tool
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return yield* Effect.fail(
|
|
59
|
+
new NoAiToolFoundError({
|
|
60
|
+
message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const extractResponseTag = (output: string) =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
// Extract content between <response> tags
|
|
68
|
+
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
69
|
+
|
|
70
|
+
if (!responseMatch || !responseMatch[1]) {
|
|
71
|
+
return yield* Effect.fail(
|
|
72
|
+
new AiResponseParseError({
|
|
73
|
+
message: 'No <response> tag found in AI output',
|
|
74
|
+
rawOutput: output,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return responseMatch[1].trim()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const runPrompt = (prompt: string, input: string) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const tool = yield* detectAiTool()
|
|
85
|
+
|
|
86
|
+
// Prepare the command based on the tool
|
|
87
|
+
const fullInput = `${prompt}\n\n${input}`
|
|
88
|
+
let command: string
|
|
89
|
+
|
|
90
|
+
switch (tool) {
|
|
91
|
+
case 'claude':
|
|
92
|
+
// Claude CLI uses -p flag for piped input
|
|
93
|
+
command = 'claude -p'
|
|
94
|
+
break
|
|
95
|
+
case 'llm':
|
|
96
|
+
// LLM CLI syntax
|
|
97
|
+
command = 'llm'
|
|
98
|
+
break
|
|
99
|
+
case 'opencode':
|
|
100
|
+
// Opencode CLI syntax
|
|
101
|
+
command = 'opencode'
|
|
102
|
+
break
|
|
103
|
+
case 'gemini':
|
|
104
|
+
// Gemini CLI syntax (adjust as needed)
|
|
105
|
+
command = 'gemini'
|
|
106
|
+
break
|
|
107
|
+
default:
|
|
108
|
+
command = tool
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Run the AI tool with the prompt and input
|
|
112
|
+
const result = yield* Effect.tryPromise({
|
|
113
|
+
try: async () => {
|
|
114
|
+
const child = require('node:child_process').spawn(command, {
|
|
115
|
+
shell: true,
|
|
116
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Write input to stdin
|
|
120
|
+
child.stdin.write(fullInput)
|
|
121
|
+
child.stdin.end()
|
|
122
|
+
|
|
123
|
+
// Collect output
|
|
124
|
+
let stdout = ''
|
|
125
|
+
let stderr = ''
|
|
126
|
+
|
|
127
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
128
|
+
stdout += data.toString()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
132
|
+
stderr += data.toString()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
136
|
+
child.on('close', (code: number) => {
|
|
137
|
+
if (code !== 0) {
|
|
138
|
+
reject(new Error(`AI tool exited with code ${code}: ${stderr}`))
|
|
139
|
+
} else {
|
|
140
|
+
resolve({ stdout, stderr })
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
child.on('error', reject)
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
catch: (error) =>
|
|
148
|
+
new AiServiceError({
|
|
149
|
+
message: `Failed to run AI tool: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
|
+
cause: error,
|
|
151
|
+
}),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Extract response tag
|
|
155
|
+
return yield* extractResponseTag(result.stdout)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return AiService.of({
|
|
159
|
+
detectAiTool,
|
|
160
|
+
extractResponseTag,
|
|
161
|
+
runPrompt,
|
|
162
|
+
})
|
|
163
|
+
}),
|
|
164
|
+
).pipe(Layer.provide(ConfigServiceLive))
|
|
165
|
+
|
|
166
|
+
// Export a simpler Live layer for backward compatibility
|
|
167
|
+
export const AiServiceLive = AiServiceEnhanced
|