@aaronshaf/ger 1.2.10 → 2.0.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/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -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 +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -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/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -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/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -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/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -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 +708 -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/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -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/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -180
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- package/src/utils.ts +0 -130
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Context, Data, Effect, Layer } from 'effect'
|
|
2
|
+
import { exec } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec)
|
|
6
|
+
|
|
7
|
+
// Shared response extraction logic for all AI tools
|
|
8
|
+
const extractResponse = (stdout: string): string => {
|
|
9
|
+
// Extract response from <response> tags or use full output
|
|
10
|
+
const responseMatch = stdout.match(/<response>([\s\S]*?)<\/response>/i)
|
|
11
|
+
return responseMatch ? responseMatch[1].trim() : stdout.trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Simple strategy focused only on review needs
|
|
15
|
+
export interface ReviewStrategyErrorFields {
|
|
16
|
+
readonly message: string
|
|
17
|
+
readonly cause?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ReviewStrategyErrorBase = Data.TaggedError(
|
|
21
|
+
'ReviewStrategyError',
|
|
22
|
+
)<ReviewStrategyErrorFields> as unknown
|
|
23
|
+
|
|
24
|
+
export class ReviewStrategyError
|
|
25
|
+
extends (ReviewStrategyErrorBase as new (
|
|
26
|
+
args: ReviewStrategyErrorFields,
|
|
27
|
+
) => ReviewStrategyErrorFields & Error & { readonly _tag: 'ReviewStrategyError' })
|
|
28
|
+
implements Error
|
|
29
|
+
{
|
|
30
|
+
readonly name = 'ReviewStrategyError'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Review strategy interface - focused on specific review patterns
|
|
34
|
+
export interface ReviewStrategy {
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly isAvailable: () => Effect.Effect<boolean, never>
|
|
37
|
+
readonly executeReview: (
|
|
38
|
+
prompt: string,
|
|
39
|
+
options?: { cwd?: string; systemPrompt?: string },
|
|
40
|
+
) => Effect.Effect<string, ReviewStrategyError>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Strategy implementations for different AI tools
|
|
44
|
+
export const claudeCliStrategy: ReviewStrategy = {
|
|
45
|
+
name: 'Claude CLI',
|
|
46
|
+
isAvailable: () =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const result = yield* Effect.tryPromise({
|
|
49
|
+
try: () => execAsync('which claude'),
|
|
50
|
+
catch: () => null,
|
|
51
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
52
|
+
|
|
53
|
+
return Boolean(result && result.stdout.trim())
|
|
54
|
+
}),
|
|
55
|
+
executeReview: (prompt, options = {}) =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const result = yield* Effect.tryPromise({
|
|
58
|
+
try: async () => {
|
|
59
|
+
const child = require('node:child_process').spawn('claude -p', {
|
|
60
|
+
shell: true,
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
cwd: options.cwd || process.cwd(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
child.stdin.write(prompt)
|
|
66
|
+
child.stdin.end()
|
|
67
|
+
|
|
68
|
+
let stdout = ''
|
|
69
|
+
let stderr = ''
|
|
70
|
+
|
|
71
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
72
|
+
stdout += data.toString()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
76
|
+
stderr += data.toString()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
80
|
+
child.on('close', (code: number) => {
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`))
|
|
83
|
+
} else {
|
|
84
|
+
resolve({ stdout, stderr })
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
child.on('error', reject)
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
catch: (error) =>
|
|
92
|
+
new ReviewStrategyError({
|
|
93
|
+
message: `Claude CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
94
|
+
cause: error,
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return extractResponse(result.stdout)
|
|
99
|
+
}),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const geminiCliStrategy: ReviewStrategy = {
|
|
103
|
+
name: 'Gemini CLI',
|
|
104
|
+
isAvailable: () =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const result = yield* Effect.tryPromise({
|
|
107
|
+
try: () => execAsync('which gemini'),
|
|
108
|
+
catch: () => null,
|
|
109
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
110
|
+
|
|
111
|
+
return Boolean(result && result.stdout.trim())
|
|
112
|
+
}),
|
|
113
|
+
executeReview: (prompt, options = {}) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const result = yield* Effect.tryPromise({
|
|
116
|
+
try: async () => {
|
|
117
|
+
const child = require('node:child_process').spawn('gemini -p', {
|
|
118
|
+
shell: true,
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
cwd: options.cwd || process.cwd(),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
child.stdin.write(prompt)
|
|
124
|
+
child.stdin.end()
|
|
125
|
+
|
|
126
|
+
let stdout = ''
|
|
127
|
+
let stderr = ''
|
|
128
|
+
|
|
129
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
130
|
+
stdout += data.toString()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
134
|
+
stderr += data.toString()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
138
|
+
child.on('close', (code: number) => {
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
reject(new Error(`Gemini CLI exited with code ${code}: ${stderr}`))
|
|
141
|
+
} else {
|
|
142
|
+
resolve({ stdout, stderr })
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
child.on('error', reject)
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
catch: (error) =>
|
|
150
|
+
new ReviewStrategyError({
|
|
151
|
+
message: `Gemini CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
152
|
+
cause: error,
|
|
153
|
+
}),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return extractResponse(result.stdout)
|
|
157
|
+
}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const openCodeCliStrategy: ReviewStrategy = {
|
|
161
|
+
name: 'OpenCode CLI',
|
|
162
|
+
isAvailable: () =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
const result = yield* Effect.tryPromise({
|
|
165
|
+
try: () => execAsync('which opencode'),
|
|
166
|
+
catch: () => null,
|
|
167
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
168
|
+
|
|
169
|
+
return Boolean(result && result.stdout.trim())
|
|
170
|
+
}),
|
|
171
|
+
executeReview: (prompt, options = {}) =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
const result = yield* Effect.tryPromise({
|
|
174
|
+
try: async () => {
|
|
175
|
+
const child = require('node:child_process').spawn('opencode -p', {
|
|
176
|
+
shell: true,
|
|
177
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
178
|
+
cwd: options.cwd || process.cwd(),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
child.stdin.write(prompt)
|
|
182
|
+
child.stdin.end()
|
|
183
|
+
|
|
184
|
+
let stdout = ''
|
|
185
|
+
let stderr = ''
|
|
186
|
+
|
|
187
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
188
|
+
stdout += data.toString()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
192
|
+
stderr += data.toString()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
196
|
+
child.on('close', (code: number) => {
|
|
197
|
+
if (code !== 0) {
|
|
198
|
+
reject(new Error(`OpenCode CLI exited with code ${code}: ${stderr}`))
|
|
199
|
+
} else {
|
|
200
|
+
resolve({ stdout, stderr })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
child.on('error', reject)
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
catch: (error) =>
|
|
208
|
+
new ReviewStrategyError({
|
|
209
|
+
message: `OpenCode CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
210
|
+
cause: error,
|
|
211
|
+
}),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return extractResponse(result.stdout)
|
|
215
|
+
}),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Review service interface using strategy pattern
|
|
219
|
+
export interface ReviewStrategyServiceImpl {
|
|
220
|
+
readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
|
|
221
|
+
readonly selectStrategy: (
|
|
222
|
+
preferredName?: string,
|
|
223
|
+
) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
|
|
224
|
+
readonly executeWithStrategy: (
|
|
225
|
+
strategy: ReviewStrategy,
|
|
226
|
+
prompt: string,
|
|
227
|
+
options?: { cwd?: string; systemPrompt?: string },
|
|
228
|
+
) => Effect.Effect<string, ReviewStrategyError>
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Export the service tag with explicit type
|
|
232
|
+
export const ReviewStrategyService: Context.Tag<
|
|
233
|
+
ReviewStrategyServiceImpl,
|
|
234
|
+
ReviewStrategyServiceImpl
|
|
235
|
+
> = Context.GenericTag<ReviewStrategyServiceImpl>('ReviewStrategyService')
|
|
236
|
+
|
|
237
|
+
export type ReviewStrategyService = Context.Tag.Identifier<typeof ReviewStrategyService>
|
|
238
|
+
|
|
239
|
+
export const ReviewStrategyServiceLive: Layer.Layer<ReviewStrategyServiceImpl> = Layer.succeed(
|
|
240
|
+
ReviewStrategyService,
|
|
241
|
+
{
|
|
242
|
+
getAvailableStrategies: () =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const strategies = [claudeCliStrategy, geminiCliStrategy, openCodeCliStrategy]
|
|
245
|
+
const available: ReviewStrategy[] = []
|
|
246
|
+
|
|
247
|
+
for (const strategy of strategies) {
|
|
248
|
+
const isAvailable = yield* strategy.isAvailable()
|
|
249
|
+
if (isAvailable) {
|
|
250
|
+
available.push(strategy)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return available
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
selectStrategy: (preferredName?: string) =>
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const strategies = [claudeCliStrategy, geminiCliStrategy, openCodeCliStrategy]
|
|
260
|
+
const available: ReviewStrategy[] = []
|
|
261
|
+
|
|
262
|
+
for (const strategy of strategies) {
|
|
263
|
+
const isAvailable = yield* strategy.isAvailable()
|
|
264
|
+
if (isAvailable) {
|
|
265
|
+
available.push(strategy)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (available.length === 0) {
|
|
270
|
+
return yield* Effect.fail(
|
|
271
|
+
new ReviewStrategyError({
|
|
272
|
+
message: 'No AI tools available. Please install claude, gemini, or opencode CLI.',
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (preferredName) {
|
|
278
|
+
const preferred = available.find((s: ReviewStrategy) =>
|
|
279
|
+
s.name.toLowerCase().includes(preferredName.toLowerCase()),
|
|
280
|
+
)
|
|
281
|
+
if (preferred) {
|
|
282
|
+
return preferred
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return available[0] // Return first available
|
|
287
|
+
}),
|
|
288
|
+
|
|
289
|
+
executeWithStrategy: (strategy, prompt, options = {}) =>
|
|
290
|
+
strategy.executeReview(prompt, options),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Schema } from '@effect/schema'
|
|
2
|
+
import type { ChangeInfo, FileDiffContent, FileInfo, RevisionInfoType } from '@/schemas/gerrit'
|
|
3
|
+
|
|
4
|
+
export const generateMockChange = (
|
|
5
|
+
overrides?: Partial<Schema.Schema.Type<typeof ChangeInfo>>,
|
|
6
|
+
): Schema.Schema.Type<typeof ChangeInfo> => {
|
|
7
|
+
const base: Schema.Schema.Type<typeof ChangeInfo> = {
|
|
8
|
+
id: 'myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940',
|
|
9
|
+
project: 'myProject',
|
|
10
|
+
branch: 'master',
|
|
11
|
+
change_id: 'I8473b95934b5732ac55d26311a706c9c2bde9940',
|
|
12
|
+
subject: 'Implementing new feature',
|
|
13
|
+
status: 'NEW' as const,
|
|
14
|
+
created: '2023-12-01 10:00:00.000000000',
|
|
15
|
+
updated: '2023-12-01 15:30:00.000000000',
|
|
16
|
+
insertions: 25,
|
|
17
|
+
deletions: 3,
|
|
18
|
+
_number: 12345,
|
|
19
|
+
owner: {
|
|
20
|
+
_account_id: 1000096,
|
|
21
|
+
name: 'John Developer',
|
|
22
|
+
email: 'john@example.com',
|
|
23
|
+
username: 'jdeveloper',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { ...base, ...overrides }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const generateMockFiles = (): Record<string, Schema.Schema.Type<typeof FileInfo>> => {
|
|
31
|
+
return {
|
|
32
|
+
'src/main.ts': {
|
|
33
|
+
status: 'M' as const,
|
|
34
|
+
lines_inserted: 15,
|
|
35
|
+
lines_deleted: 3,
|
|
36
|
+
size_delta: 120,
|
|
37
|
+
size: 1200,
|
|
38
|
+
},
|
|
39
|
+
'tests/main.test.ts': {
|
|
40
|
+
status: 'A' as const,
|
|
41
|
+
lines_inserted: 45,
|
|
42
|
+
lines_deleted: 0,
|
|
43
|
+
size_delta: 450,
|
|
44
|
+
size: 450,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const generateMockFileDiff = (): Schema.Schema.Type<typeof FileDiffContent> => {
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
ab: ['function main() {', ' console.log("Hello, world!")'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
a: [' return 0'],
|
|
57
|
+
b: [' return process.exit(0)'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
ab: ['}'],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
change_type: 'MODIFIED' as const,
|
|
64
|
+
diff_header: ['--- a/src/main.ts', '+++ b/src/main.ts'],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const generateMockAccount = () => ({
|
|
69
|
+
_account_id: 1000096,
|
|
70
|
+
name: 'Test User',
|
|
71
|
+
email: 'test@example.com',
|
|
72
|
+
username: 'testuser',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate mock revision data to be included in ChangeInfo
|
|
77
|
+
* Used when simulating API responses with CURRENT_REVISION option
|
|
78
|
+
*/
|
|
79
|
+
export const generateMockRevision = (
|
|
80
|
+
patchsetNumber = 1,
|
|
81
|
+
sha = '54795ce71b351480c887e92aa0e5b9a57aef58ab',
|
|
82
|
+
): RevisionInfoType => ({
|
|
83
|
+
kind: 'REWORK',
|
|
84
|
+
_number: patchsetNumber,
|
|
85
|
+
created: '2023-12-01 10:00:00.000000000',
|
|
86
|
+
uploader: {
|
|
87
|
+
_account_id: 1000096,
|
|
88
|
+
name: 'John Developer',
|
|
89
|
+
email: 'john@example.com',
|
|
90
|
+
},
|
|
91
|
+
ref: `refs/changes/${String(Math.floor(12345 % 100)).padStart(2, '0')}/12345/${patchsetNumber}`,
|
|
92
|
+
fetch: {
|
|
93
|
+
http: {
|
|
94
|
+
url: 'https://gerrit.example.com/myProject',
|
|
95
|
+
ref: `refs/changes/${String(Math.floor(12345 % 100)).padStart(2, '0')}/12345/${patchsetNumber}`,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
commit: {
|
|
99
|
+
commit: sha,
|
|
100
|
+
parents: [
|
|
101
|
+
{
|
|
102
|
+
commit: 'parent-sha-1234567890abcdef',
|
|
103
|
+
subject: 'Parent commit',
|
|
104
|
+
},
|
|
105
|
+
] as const,
|
|
106
|
+
author: {
|
|
107
|
+
name: 'John Developer',
|
|
108
|
+
email: 'john@example.com',
|
|
109
|
+
date: '2023-12-01 10:00:00.000000000',
|
|
110
|
+
},
|
|
111
|
+
committer: {
|
|
112
|
+
name: 'John Developer',
|
|
113
|
+
email: 'john@example.com',
|
|
114
|
+
date: '2023-12-01 10:00:00.000000000',
|
|
115
|
+
},
|
|
116
|
+
subject: 'Implementing new feature',
|
|
117
|
+
message: 'Implementing new feature\n\nThis is the full commit message.',
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate mock change with revision data (simulates API response with CURRENT_REVISION option)
|
|
123
|
+
*/
|
|
124
|
+
export const generateMockChangeWithRevision = (
|
|
125
|
+
overrides?: Partial<Schema.Schema.Type<typeof ChangeInfo>>,
|
|
126
|
+
patchsetNumber = 1,
|
|
127
|
+
): Schema.Schema.Type<typeof ChangeInfo> => {
|
|
128
|
+
const sha = '54795ce71b351480c887e92aa0e5b9a57aef58ab'
|
|
129
|
+
const revision = generateMockRevision(patchsetNumber, sha)
|
|
130
|
+
|
|
131
|
+
return generateMockChange({
|
|
132
|
+
current_revision: sha,
|
|
133
|
+
revisions: {
|
|
134
|
+
[sha]: revision,
|
|
135
|
+
},
|
|
136
|
+
...overrides,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
isChangeId,
|
|
4
|
+
isChangeNumber,
|
|
5
|
+
isValidChangeIdentifier,
|
|
6
|
+
normalizeChangeIdentifier,
|
|
7
|
+
getIdentifierType,
|
|
8
|
+
} from './change-id'
|
|
9
|
+
|
|
10
|
+
describe('change-id utilities', () => {
|
|
11
|
+
describe('isChangeId', () => {
|
|
12
|
+
test('returns true for valid Change-ID format', () => {
|
|
13
|
+
expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(true)
|
|
14
|
+
expect(isChangeId('I0123456789abcdef0123456789abcdef01234567')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns false for invalid Change-ID format', () => {
|
|
18
|
+
expect(isChangeId('392385')).toBe(false)
|
|
19
|
+
expect(isChangeId('if5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false) // lowercase 'i'
|
|
20
|
+
expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b')).toBe(false) // too short
|
|
21
|
+
expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b11')).toBe(false) // too long
|
|
22
|
+
expect(isChangeId('Gf5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false) // wrong prefix
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('isChangeNumber', () => {
|
|
27
|
+
test('returns true for numeric strings', () => {
|
|
28
|
+
expect(isChangeNumber('392385')).toBe(true)
|
|
29
|
+
expect(isChangeNumber('12345')).toBe(true)
|
|
30
|
+
expect(isChangeNumber('1')).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('returns false for non-numeric strings', () => {
|
|
34
|
+
expect(isChangeNumber('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false)
|
|
35
|
+
expect(isChangeNumber('abc')).toBe(false)
|
|
36
|
+
expect(isChangeNumber('123abc')).toBe(false)
|
|
37
|
+
expect(isChangeNumber('')).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('isValidChangeIdentifier', () => {
|
|
42
|
+
test('returns true for valid change numbers', () => {
|
|
43
|
+
expect(isValidChangeIdentifier('392385')).toBe(true)
|
|
44
|
+
expect(isValidChangeIdentifier('12345')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('returns true for valid Change-IDs', () => {
|
|
48
|
+
expect(isValidChangeIdentifier('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(true)
|
|
49
|
+
expect(isValidChangeIdentifier('I0123456789abcdef0123456789abcdef01234567')).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns false for invalid identifiers', () => {
|
|
53
|
+
expect(isValidChangeIdentifier('abc')).toBe(false)
|
|
54
|
+
expect(isValidChangeIdentifier('I123')).toBe(false)
|
|
55
|
+
expect(isValidChangeIdentifier('')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('normalizeChangeIdentifier', () => {
|
|
60
|
+
test('returns trimmed change number', () => {
|
|
61
|
+
expect(normalizeChangeIdentifier('392385')).toBe('392385')
|
|
62
|
+
expect(normalizeChangeIdentifier(' 392385 ')).toBe('392385')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('returns trimmed Change-ID', () => {
|
|
66
|
+
expect(normalizeChangeIdentifier('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(
|
|
67
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
68
|
+
)
|
|
69
|
+
expect(normalizeChangeIdentifier(' If5a3ae8cb5a107e187447802358417f311d0c4b1 ')).toBe(
|
|
70
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('throws error for invalid identifiers', () => {
|
|
75
|
+
expect(() => normalizeChangeIdentifier('abc')).toThrow(/Invalid change identifier/)
|
|
76
|
+
expect(() => normalizeChangeIdentifier('I123')).toThrow(/Invalid change identifier/)
|
|
77
|
+
expect(() => normalizeChangeIdentifier('')).toThrow(/Invalid change identifier/)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('getIdentifierType', () => {
|
|
82
|
+
test('returns "change-number" for numeric strings', () => {
|
|
83
|
+
expect(getIdentifierType('392385')).toBe('change-number')
|
|
84
|
+
expect(getIdentifierType(' 12345 ')).toBe('change-number')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('returns "change-id" for Change-ID format', () => {
|
|
88
|
+
expect(getIdentifierType('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe('change-id')
|
|
89
|
+
expect(getIdentifierType(' If5a3ae8cb5a107e187447802358417f311d0c4b1 ')).toBe('change-id')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('returns "invalid" for invalid identifiers', () => {
|
|
93
|
+
expect(getIdentifierType('abc')).toBe('invalid')
|
|
94
|
+
expect(getIdentifierType('I123')).toBe('invalid')
|
|
95
|
+
expect(getIdentifierType('')).toBe('invalid')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for handling Gerrit change identifiers
|
|
3
|
+
* Supports both numeric change numbers (e.g., "392385") and Change-IDs (e.g., "If5a3ae8cb5a107e187447802358417f311d0c4b1")
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates if a string is a valid Gerrit Change-ID format
|
|
8
|
+
* Change-IDs start with 'I' followed by a 40-character SHA-1 hash
|
|
9
|
+
*/
|
|
10
|
+
export function isChangeId(value: string): boolean {
|
|
11
|
+
return /^I[0-9a-f]{40}$/.test(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validates if a string is a numeric change number
|
|
16
|
+
*/
|
|
17
|
+
export function isChangeNumber(value: string): boolean {
|
|
18
|
+
return /^\d+$/.test(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates if a string is either a valid Change-ID or change number
|
|
23
|
+
*/
|
|
24
|
+
export function isValidChangeIdentifier(value: string): boolean {
|
|
25
|
+
return isChangeId(value) || isChangeNumber(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes a change identifier for use with Gerrit API
|
|
30
|
+
* Gerrit API accepts both formats, so we just validate and return as-is
|
|
31
|
+
*
|
|
32
|
+
* @param value - Either a numeric change number or a Change-ID
|
|
33
|
+
* @returns The normalized identifier
|
|
34
|
+
* @throws Error if the identifier is invalid
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeChangeIdentifier(value: string): string {
|
|
37
|
+
const trimmed = value.trim()
|
|
38
|
+
|
|
39
|
+
if (!isValidChangeIdentifier(trimmed)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Invalid change identifier: "${value}". Expected either a numeric change number (e.g., "392385") or a Change-ID starting with "I" (e.g., "If5a3ae8cb5a107e187447802358417f311d0c4b1")`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return trimmed
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gets a user-friendly description of what type of identifier was provided
|
|
50
|
+
*/
|
|
51
|
+
export function getIdentifierType(value: string): 'change-number' | 'change-id' | 'invalid' {
|
|
52
|
+
const trimmed = value.trim()
|
|
53
|
+
|
|
54
|
+
if (isChangeNumber(trimmed)) {
|
|
55
|
+
return 'change-number'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isChangeId(trimmed)) {
|
|
59
|
+
return 'change-id'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return 'invalid'
|
|
63
|
+
}
|