@aaronshaf/ger 0.2.0 → 0.2.2
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/EXAMPLES.md +409 -0
- package/README.md +9 -2
- package/index.ts +219 -0
- package/package.json +46 -1
- package/src/cli/commands/review.ts +5 -131
- package/src/cli/commands/setup.ts +2 -1
- package/src/cli/index.ts +1 -1
- package/src/services/git-worktree.ts +14 -25
- package/src/utils/index.ts +55 -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 +1 -1
- package/tests/setup.test.ts +7 -11
- package/tests/unit/git-branch-detection.test.ts +1 -2
- package/tests/unit/services/review-strategy.test.ts +2 -2
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# Programmatic Usage Examples
|
|
2
|
+
|
|
3
|
+
This package can be used both as a CLI tool and as a library. Below are examples of using `@aaronshaf/ger` programmatically with Effect-TS.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @aaronshaf/ger
|
|
9
|
+
# or
|
|
10
|
+
npm install @aaronshaf/ger
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic Setup
|
|
14
|
+
|
|
15
|
+
All services in this package are built with Effect-TS, providing type-safe, composable operations.
|
|
16
|
+
|
|
17
|
+
### Import the services
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { Effect, pipe } from 'effect'
|
|
21
|
+
import {
|
|
22
|
+
GerritApiService,
|
|
23
|
+
GerritApiServiceLive,
|
|
24
|
+
ConfigServiceLive,
|
|
25
|
+
type ChangeInfo,
|
|
26
|
+
} from '@aaronshaf/ger'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
### Using Environment Variables
|
|
32
|
+
|
|
33
|
+
Set these environment variables before running your program:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export GERRIT_HOST="https://gerrit.example.com"
|
|
37
|
+
export GERRIT_USERNAME="your-username"
|
|
38
|
+
export GERRIT_PASSWORD="your-http-password"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Using File-Based Config
|
|
42
|
+
|
|
43
|
+
Or run the CLI once to set up configuration:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ger setup
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This stores credentials in `~/.ger/config.json`.
|
|
50
|
+
|
|
51
|
+
## Examples
|
|
52
|
+
|
|
53
|
+
### 1. Get Change Information
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { Effect, pipe } from 'effect'
|
|
57
|
+
import {
|
|
58
|
+
GerritApiService,
|
|
59
|
+
GerritApiServiceLive,
|
|
60
|
+
ConfigServiceLive,
|
|
61
|
+
} from '@aaronshaf/ger'
|
|
62
|
+
|
|
63
|
+
const getChangeDetails = (changeId: string) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const api = yield* GerritApiService
|
|
66
|
+
const change = yield* api.getChange(changeId)
|
|
67
|
+
|
|
68
|
+
console.log(`Change: ${change.subject}`)
|
|
69
|
+
console.log(`Status: ${change.status}`)
|
|
70
|
+
console.log(`Owner: ${change.owner?.name || 'Unknown'}`)
|
|
71
|
+
|
|
72
|
+
return change
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Run the program
|
|
76
|
+
const program = pipe(
|
|
77
|
+
getChangeDetails('12345'),
|
|
78
|
+
Effect.provide(GerritApiServiceLive),
|
|
79
|
+
Effect.provide(ConfigServiceLive)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
Effect.runPromise(program)
|
|
83
|
+
.then(() => console.log('Done!'))
|
|
84
|
+
.catch(console.error)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. List Open Changes
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { Effect, pipe } from 'effect'
|
|
91
|
+
import {
|
|
92
|
+
GerritApiService,
|
|
93
|
+
GerritApiServiceLive,
|
|
94
|
+
ConfigServiceLive,
|
|
95
|
+
} from '@aaronshaf/ger'
|
|
96
|
+
|
|
97
|
+
const listMyChanges = Effect.gen(function* () {
|
|
98
|
+
const api = yield* GerritApiService
|
|
99
|
+
|
|
100
|
+
// Query for your open changes
|
|
101
|
+
const changes = yield* api.listChanges('is:open owner:self')
|
|
102
|
+
|
|
103
|
+
console.log(`You have ${changes.length} open changes:`)
|
|
104
|
+
for (const change of changes) {
|
|
105
|
+
console.log(` - #${change._number}: ${change.subject}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return changes
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const program = pipe(
|
|
112
|
+
listMyChanges,
|
|
113
|
+
Effect.provide(GerritApiServiceLive),
|
|
114
|
+
Effect.provide(ConfigServiceLive)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
Effect.runPromise(program).catch(console.error)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 3. Post a Comment
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { Effect, pipe } from 'effect'
|
|
124
|
+
import {
|
|
125
|
+
GerritApiService,
|
|
126
|
+
GerritApiServiceLive,
|
|
127
|
+
ConfigServiceLive,
|
|
128
|
+
type ReviewInput,
|
|
129
|
+
} from '@aaronshaf/ger'
|
|
130
|
+
|
|
131
|
+
const postComment = (changeId: string) =>
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const api = yield* GerritApiService
|
|
134
|
+
|
|
135
|
+
const review: ReviewInput = {
|
|
136
|
+
message: 'Looks good to me!',
|
|
137
|
+
labels: {
|
|
138
|
+
'Code-Review': 1,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
yield* api.postReview(changeId, review)
|
|
143
|
+
console.log('Comment posted successfully!')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const program = pipe(
|
|
147
|
+
postComment('12345'),
|
|
148
|
+
Effect.provide(GerritApiServiceLive),
|
|
149
|
+
Effect.provide(ConfigServiceLive)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
Effect.runPromise(program).catch(console.error)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### 4. Post Inline Comments
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { Effect, pipe } from 'effect'
|
|
159
|
+
import {
|
|
160
|
+
GerritApiService,
|
|
161
|
+
GerritApiServiceLive,
|
|
162
|
+
ConfigServiceLive,
|
|
163
|
+
type ReviewInput,
|
|
164
|
+
} from '@aaronshaf/ger'
|
|
165
|
+
|
|
166
|
+
const postInlineComments = (changeId: string) =>
|
|
167
|
+
Effect.gen(function* () {
|
|
168
|
+
const api = yield* GerritApiService
|
|
169
|
+
|
|
170
|
+
const review: ReviewInput = {
|
|
171
|
+
message: 'Review complete',
|
|
172
|
+
comments: {
|
|
173
|
+
'src/api.ts': [
|
|
174
|
+
{
|
|
175
|
+
line: 42,
|
|
176
|
+
message: 'Consider using const here for immutability',
|
|
177
|
+
unresolved: false,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
line: 55,
|
|
181
|
+
message: 'This could cause a security issue',
|
|
182
|
+
unresolved: true,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
'src/utils.ts': [
|
|
186
|
+
{
|
|
187
|
+
line: 10,
|
|
188
|
+
message: 'Nice refactor!',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
yield* api.postReview(changeId, review)
|
|
195
|
+
console.log('Inline comments posted!')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const program = pipe(
|
|
199
|
+
postInlineComments('12345'),
|
|
200
|
+
Effect.provide(GerritApiServiceLive),
|
|
201
|
+
Effect.provide(ConfigServiceLive)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
Effect.runPromise(program).catch(console.error)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### 5. Get Diff for a Change
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
import { Effect, pipe } from 'effect'
|
|
211
|
+
import {
|
|
212
|
+
GerritApiService,
|
|
213
|
+
GerritApiServiceLive,
|
|
214
|
+
ConfigServiceLive,
|
|
215
|
+
type DiffOptions,
|
|
216
|
+
} from '@aaronshaf/ger'
|
|
217
|
+
|
|
218
|
+
const getDiff = (changeId: string) =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const api = yield* GerritApiService
|
|
221
|
+
|
|
222
|
+
// Get unified diff format (default)
|
|
223
|
+
const diff = yield* api.getDiff(changeId, { format: 'unified' })
|
|
224
|
+
console.log('Diff:', diff)
|
|
225
|
+
|
|
226
|
+
// Or get list of changed files
|
|
227
|
+
const files = yield* api.getDiff(changeId, { format: 'files' })
|
|
228
|
+
console.log('Changed files:', files)
|
|
229
|
+
|
|
230
|
+
return diff
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const program = pipe(
|
|
234
|
+
getDiff('12345'),
|
|
235
|
+
Effect.provide(GerritApiServiceLive),
|
|
236
|
+
Effect.provide(ConfigServiceLive)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
Effect.runPromise(program).catch(console.error)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 6. Test Connection
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { Effect, pipe } from 'effect'
|
|
246
|
+
import {
|
|
247
|
+
GerritApiService,
|
|
248
|
+
GerritApiServiceLive,
|
|
249
|
+
ConfigServiceLive,
|
|
250
|
+
} from '@aaronshaf/ger'
|
|
251
|
+
|
|
252
|
+
const testConnection = Effect.gen(function* () {
|
|
253
|
+
const api = yield* GerritApiService
|
|
254
|
+
const isConnected = yield* api.testConnection
|
|
255
|
+
|
|
256
|
+
if (isConnected) {
|
|
257
|
+
console.log('✓ Connected to Gerrit!')
|
|
258
|
+
} else {
|
|
259
|
+
console.log('✗ Connection failed')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return isConnected
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const program = pipe(
|
|
266
|
+
testConnection,
|
|
267
|
+
Effect.provide(GerritApiServiceLive),
|
|
268
|
+
Effect.provide(ConfigServiceLive)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
Effect.runPromise(program).catch(console.error)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### 7. Error Handling with Effect
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { Effect, pipe, Console } from 'effect'
|
|
278
|
+
import {
|
|
279
|
+
GerritApiService,
|
|
280
|
+
GerritApiServiceLive,
|
|
281
|
+
ConfigServiceLive,
|
|
282
|
+
ApiError,
|
|
283
|
+
ConfigError,
|
|
284
|
+
} from '@aaronshaf/ger'
|
|
285
|
+
|
|
286
|
+
const safeGetChange = (changeId: string) =>
|
|
287
|
+
Effect.gen(function* () {
|
|
288
|
+
const api = yield* GerritApiService
|
|
289
|
+
const change = yield* api.getChange(changeId)
|
|
290
|
+
return change
|
|
291
|
+
}).pipe(
|
|
292
|
+
Effect.catchTag('ApiError', (error) =>
|
|
293
|
+
Console.error(`API Error: ${error.message}`).pipe(
|
|
294
|
+
Effect.map(() => null)
|
|
295
|
+
)
|
|
296
|
+
),
|
|
297
|
+
Effect.catchTag('ConfigError', (error) =>
|
|
298
|
+
Console.error(`Config Error: ${error.message}`).pipe(
|
|
299
|
+
Effect.map(() => null)
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const program = pipe(
|
|
305
|
+
safeGetChange('invalid-change'),
|
|
306
|
+
Effect.provide(GerritApiServiceLive),
|
|
307
|
+
Effect.provide(ConfigServiceLive)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
Effect.runPromise(program)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### 8. Using Utilities
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
import {
|
|
317
|
+
normalizeChangeIdentifier,
|
|
318
|
+
extractChangeIdFromCommitMessage,
|
|
319
|
+
extractChangeNumber,
|
|
320
|
+
normalizeGerritHost,
|
|
321
|
+
} from '@aaronshaf/ger'
|
|
322
|
+
|
|
323
|
+
// Normalize change identifiers
|
|
324
|
+
const normalized = normalizeChangeIdentifier('12345')
|
|
325
|
+
// or
|
|
326
|
+
const normalizedId = normalizeChangeIdentifier('If5a3ae8cb5a107e187447802358417f311d0c4b1')
|
|
327
|
+
|
|
328
|
+
// Extract change ID from commit message
|
|
329
|
+
const commitMsg = `feat: add feature
|
|
330
|
+
|
|
331
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
332
|
+
|
|
333
|
+
const changeId = extractChangeIdFromCommitMessage(commitMsg)
|
|
334
|
+
console.log(changeId) // "If5a3ae8cb5a107e187447802358417f311d0c4b1"
|
|
335
|
+
|
|
336
|
+
// Extract change number from Gerrit URL
|
|
337
|
+
const url = 'https://gerrit.example.com/c/project/+/12345'
|
|
338
|
+
const changeNumber = extractChangeNumber(url)
|
|
339
|
+
console.log(changeNumber) // "12345"
|
|
340
|
+
|
|
341
|
+
// Normalize Gerrit host
|
|
342
|
+
const host = normalizeGerritHost('gerrit.example.com')
|
|
343
|
+
console.log(host) // "https://gerrit.example.com"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### 9. Working with Schemas
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
import { Schema } from '@effect/schema'
|
|
350
|
+
import { Effect } from 'effect'
|
|
351
|
+
import { ChangeInfo, ReviewInput } from '@aaronshaf/ger'
|
|
352
|
+
|
|
353
|
+
// Validate and decode API responses
|
|
354
|
+
const validateChange = (data: unknown) =>
|
|
355
|
+
Schema.decodeUnknown(ChangeInfo)(data)
|
|
356
|
+
|
|
357
|
+
// Validate review input before sending
|
|
358
|
+
const validateReview = (review: unknown) =>
|
|
359
|
+
Schema.decodeUnknown(ReviewInput)(review)
|
|
360
|
+
|
|
361
|
+
// Use in an Effect program
|
|
362
|
+
const safeReview = Effect.gen(function* () {
|
|
363
|
+
const review = {
|
|
364
|
+
message: 'LGTM',
|
|
365
|
+
labels: { 'Code-Review': 2 },
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const validated = yield* validateReview(review)
|
|
369
|
+
console.log('Review is valid:', validated)
|
|
370
|
+
|
|
371
|
+
return validated
|
|
372
|
+
})
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Direct Module Access
|
|
376
|
+
|
|
377
|
+
You can also import directly from specific modules:
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// Import from specific services
|
|
381
|
+
import { GerritApiService, GerritApiServiceLive } from '@aaronshaf/ger/api'
|
|
382
|
+
import { ConfigService, ConfigServiceLive } from '@aaronshaf/ger/services/config'
|
|
383
|
+
|
|
384
|
+
// Import from specific schemas
|
|
385
|
+
import { ChangeInfo, ReviewInput } from '@aaronshaf/ger/schemas/gerrit'
|
|
386
|
+
|
|
387
|
+
// Import utilities
|
|
388
|
+
import { normalizeChangeIdentifier, extractChangeNumber } from '@aaronshaf/ger/utils'
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## TypeScript Configuration
|
|
392
|
+
|
|
393
|
+
Make sure your `tsconfig.json` includes:
|
|
394
|
+
|
|
395
|
+
```json
|
|
396
|
+
{
|
|
397
|
+
"compilerOptions": {
|
|
398
|
+
"moduleResolution": "bundler",
|
|
399
|
+
"allowImportingTsExtensions": true,
|
|
400
|
+
"strict": true
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## More Information
|
|
406
|
+
|
|
407
|
+
- See the [main README](./README.md) for CLI usage
|
|
408
|
+
- Check out the [Effect documentation](https://effect.website/) to learn more about Effect-TS
|
|
409
|
+
- View the type definitions in your IDE for detailed API documentation
|
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ ger comments 12345 --pretty
|
|
|
199
199
|
Extract URLs from change messages and comments for automation and scripting:
|
|
200
200
|
|
|
201
201
|
```bash
|
|
202
|
-
# Extract
|
|
202
|
+
# Extract URLs from current HEAD commit's change (auto-detect)
|
|
203
203
|
ger extract-url "build-summary-report"
|
|
204
204
|
|
|
205
205
|
# Get the latest build URL (using tail)
|
|
@@ -208,9 +208,15 @@ ger extract-url "build-summary-report" | tail -1
|
|
|
208
208
|
# Get the first/oldest build URL (using head)
|
|
209
209
|
ger extract-url "jenkins" | head -1
|
|
210
210
|
|
|
211
|
-
# For a specific change
|
|
211
|
+
# For a specific change (using change number)
|
|
212
212
|
ger extract-url "build-summary" 12345
|
|
213
213
|
|
|
214
|
+
# For a specific change (using Change-ID)
|
|
215
|
+
ger extract-url "build-summary" If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
216
|
+
|
|
217
|
+
# Chain with other tools for specific change
|
|
218
|
+
ger extract-url "build-summary-report" 12345 | tail -1 | jk failures --smart --xml
|
|
219
|
+
|
|
214
220
|
# Use regex for precise matching
|
|
215
221
|
ger extract-url "job/Canvas/job/main/\d+/" --regex
|
|
216
222
|
|
|
@@ -225,6 +231,7 @@ ger extract-url "jenkins" --xml
|
|
|
225
231
|
```
|
|
226
232
|
|
|
227
233
|
#### How it works:
|
|
234
|
+
- **Change detection**: Auto-detects Change-ID from HEAD commit if not specified, or accepts explicit change number/Change-ID
|
|
228
235
|
- **Pattern matching**: Substring match by default, regex with `--regex`
|
|
229
236
|
- **Sources**: Searches messages by default, add `--include-comments` to include inline comments
|
|
230
237
|
- **Ordering**: URLs are output in chronological order (oldest first)
|
package/index.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aaronshaf/ger - Gerrit CLI and SDK
|
|
3
|
+
*
|
|
4
|
+
* This package provides both a CLI tool and a programmatic API for interacting with Gerrit Code Review.
|
|
5
|
+
* Built with Effect-TS for type-safe, composable operations.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*
|
|
9
|
+
* @example Basic usage with Effect
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { Effect, pipe } from 'effect'
|
|
12
|
+
* import {
|
|
13
|
+
* GerritApiService,
|
|
14
|
+
* GerritApiServiceLive,
|
|
15
|
+
* ConfigServiceLive,
|
|
16
|
+
* } from '@aaronshaf/ger'
|
|
17
|
+
*
|
|
18
|
+
* const program = Effect.gen(function* () {
|
|
19
|
+
* const api = yield* GerritApiService
|
|
20
|
+
* const change = yield* api.getChange('12345')
|
|
21
|
+
* console.log(change.subject)
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* const runnable = pipe(
|
|
25
|
+
* program,
|
|
26
|
+
* Effect.provide(GerritApiServiceLive),
|
|
27
|
+
* Effect.provide(ConfigServiceLive)
|
|
28
|
+
* )
|
|
29
|
+
*
|
|
30
|
+
* Effect.runPromise(runnable)
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Core API Service
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
// Service tag and implementation
|
|
40
|
+
GerritApiService,
|
|
41
|
+
GerritApiServiceLive,
|
|
42
|
+
// Types
|
|
43
|
+
type GerritApiServiceImpl,
|
|
44
|
+
// Errors
|
|
45
|
+
ApiError,
|
|
46
|
+
type ApiErrorFields,
|
|
47
|
+
} from './src/api/gerrit'
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Configuration Service
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
// Service tag and implementation
|
|
55
|
+
ConfigService,
|
|
56
|
+
ConfigServiceLive,
|
|
57
|
+
// Types
|
|
58
|
+
type ConfigServiceImpl,
|
|
59
|
+
// Errors
|
|
60
|
+
ConfigError,
|
|
61
|
+
type ConfigErrorFields,
|
|
62
|
+
} from './src/services/config'
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Review Strategy Service
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
// Strategy types
|
|
70
|
+
type ReviewStrategy,
|
|
71
|
+
// Built-in strategies
|
|
72
|
+
claudeCliStrategy,
|
|
73
|
+
geminiCliStrategy,
|
|
74
|
+
openCodeCliStrategy,
|
|
75
|
+
// Service
|
|
76
|
+
ReviewStrategyService,
|
|
77
|
+
ReviewStrategyServiceLive,
|
|
78
|
+
type ReviewStrategyServiceImpl,
|
|
79
|
+
// Errors
|
|
80
|
+
ReviewStrategyError,
|
|
81
|
+
type ReviewStrategyErrorFields,
|
|
82
|
+
} from './src/services/review-strategy'
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Git Worktree Service
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
// Service tag and implementation
|
|
90
|
+
GitWorktreeService,
|
|
91
|
+
GitWorktreeServiceLive,
|
|
92
|
+
type GitWorktreeServiceImpl,
|
|
93
|
+
// Types
|
|
94
|
+
type WorktreeInfo,
|
|
95
|
+
// Errors
|
|
96
|
+
WorktreeCreationError,
|
|
97
|
+
type WorktreeCreationErrorFields,
|
|
98
|
+
PatchsetFetchError,
|
|
99
|
+
type PatchsetFetchErrorFields,
|
|
100
|
+
DirtyRepoError,
|
|
101
|
+
type DirtyRepoErrorFields,
|
|
102
|
+
NotGitRepoError,
|
|
103
|
+
type NotGitRepoErrorFields,
|
|
104
|
+
type GitWorktreeError,
|
|
105
|
+
} from './src/services/git-worktree'
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Schemas and Types
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
// Authentication
|
|
113
|
+
GerritCredentials,
|
|
114
|
+
type GerritCredentials as GerritCredentialsType,
|
|
115
|
+
// Changes
|
|
116
|
+
ChangeInfo,
|
|
117
|
+
type ChangeInfo as ChangeInfoType,
|
|
118
|
+
// Comments
|
|
119
|
+
CommentInput,
|
|
120
|
+
type CommentInput as CommentInputType,
|
|
121
|
+
CommentInfo,
|
|
122
|
+
type CommentInfo as CommentInfoType,
|
|
123
|
+
// Messages
|
|
124
|
+
MessageInfo,
|
|
125
|
+
type MessageInfo as MessageInfoType,
|
|
126
|
+
// Reviews
|
|
127
|
+
ReviewInput,
|
|
128
|
+
type ReviewInput as ReviewInputType,
|
|
129
|
+
// Files and Diffs
|
|
130
|
+
FileInfo,
|
|
131
|
+
type FileInfo as FileInfoType,
|
|
132
|
+
FileDiffContent,
|
|
133
|
+
type FileDiffContent as FileDiffContentType,
|
|
134
|
+
RevisionInfo,
|
|
135
|
+
type RevisionInfo as RevisionInfoType,
|
|
136
|
+
// Diff Options
|
|
137
|
+
DiffFormat,
|
|
138
|
+
type DiffFormat as DiffFormatType,
|
|
139
|
+
DiffOptions,
|
|
140
|
+
type DiffOptions as DiffOptionsType,
|
|
141
|
+
DiffCommandOptions,
|
|
142
|
+
type DiffCommandOptions as DiffCommandOptionsType,
|
|
143
|
+
// Errors
|
|
144
|
+
GerritError,
|
|
145
|
+
type GerritError as GerritErrorType,
|
|
146
|
+
} from './src/schemas/gerrit'
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
// Config schemas
|
|
150
|
+
AppConfig,
|
|
151
|
+
type AppConfig as AppConfigType,
|
|
152
|
+
AiConfig,
|
|
153
|
+
type AiConfig as AiConfigType,
|
|
154
|
+
// Utilities
|
|
155
|
+
aiConfigFromFlat,
|
|
156
|
+
migrateFromNestedConfig,
|
|
157
|
+
} from './src/schemas/config'
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Utilities
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
export {
|
|
164
|
+
// Change ID handling
|
|
165
|
+
normalizeChangeIdentifier,
|
|
166
|
+
isChangeId,
|
|
167
|
+
isChangeNumber,
|
|
168
|
+
isValidChangeIdentifier,
|
|
169
|
+
getIdentifierType,
|
|
170
|
+
} from './src/utils/change-id'
|
|
171
|
+
|
|
172
|
+
export {
|
|
173
|
+
// Git commit utilities
|
|
174
|
+
extractChangeIdFromCommitMessage,
|
|
175
|
+
getLastCommitMessage,
|
|
176
|
+
getChangeIdFromHead,
|
|
177
|
+
GitError,
|
|
178
|
+
NoChangeIdError,
|
|
179
|
+
} from './src/utils/git-commit'
|
|
180
|
+
|
|
181
|
+
export {
|
|
182
|
+
// URL parsing
|
|
183
|
+
extractChangeNumber,
|
|
184
|
+
normalizeGerritHost,
|
|
185
|
+
isValidChangeId,
|
|
186
|
+
} from './src/utils/url-parser'
|
|
187
|
+
|
|
188
|
+
export {
|
|
189
|
+
// Message filtering
|
|
190
|
+
filterMeaningfulMessages,
|
|
191
|
+
sortMessagesByDate,
|
|
192
|
+
} from './src/utils/message-filters'
|
|
193
|
+
|
|
194
|
+
export {
|
|
195
|
+
// Shell safety
|
|
196
|
+
sanitizeCDATA,
|
|
197
|
+
} from './src/utils/shell-safety'
|
|
198
|
+
|
|
199
|
+
export {
|
|
200
|
+
// Formatters
|
|
201
|
+
formatDate,
|
|
202
|
+
getStatusIndicator,
|
|
203
|
+
colors,
|
|
204
|
+
} from './src/utils/formatters'
|
|
205
|
+
|
|
206
|
+
export {
|
|
207
|
+
// Comment formatters
|
|
208
|
+
formatCommentsPretty,
|
|
209
|
+
formatCommentsXml,
|
|
210
|
+
type CommentWithContext,
|
|
211
|
+
} from './src/utils/comment-formatters'
|
|
212
|
+
|
|
213
|
+
export {
|
|
214
|
+
// Diff formatters
|
|
215
|
+
formatDiffPretty,
|
|
216
|
+
formatDiffSummary,
|
|
217
|
+
formatFilesList,
|
|
218
|
+
extractDiffStats,
|
|
219
|
+
} from './src/utils/diff-formatters'
|
package/package.json
CHANGED
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaronshaf/ger",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gerrit",
|
|
7
|
+
"code-review",
|
|
8
|
+
"cli",
|
|
9
|
+
"sdk",
|
|
10
|
+
"effect",
|
|
11
|
+
"effect-ts",
|
|
12
|
+
"typescript",
|
|
13
|
+
"api-client"
|
|
14
|
+
],
|
|
4
15
|
"module": "index.ts",
|
|
5
16
|
"type": "module",
|
|
6
17
|
"bin": {
|
|
7
18
|
"ger": "./bin/ger"
|
|
8
19
|
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./index.ts",
|
|
23
|
+
"types": "./index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./api": {
|
|
26
|
+
"import": "./src/api/gerrit.ts",
|
|
27
|
+
"types": "./src/api/gerrit.ts"
|
|
28
|
+
},
|
|
29
|
+
"./services/config": {
|
|
30
|
+
"import": "./src/services/config.ts",
|
|
31
|
+
"types": "./src/services/config.ts"
|
|
32
|
+
},
|
|
33
|
+
"./services/review-strategy": {
|
|
34
|
+
"import": "./src/services/review-strategy.ts",
|
|
35
|
+
"types": "./src/services/review-strategy.ts"
|
|
36
|
+
},
|
|
37
|
+
"./services/git-worktree": {
|
|
38
|
+
"import": "./src/services/git-worktree.ts",
|
|
39
|
+
"types": "./src/services/git-worktree.ts"
|
|
40
|
+
},
|
|
41
|
+
"./schemas/gerrit": {
|
|
42
|
+
"import": "./src/schemas/gerrit.ts",
|
|
43
|
+
"types": "./src/schemas/gerrit.ts"
|
|
44
|
+
},
|
|
45
|
+
"./schemas/config": {
|
|
46
|
+
"import": "./src/schemas/config.ts",
|
|
47
|
+
"types": "./src/schemas/config.ts"
|
|
48
|
+
},
|
|
49
|
+
"./utils": {
|
|
50
|
+
"import": "./src/utils/index.ts",
|
|
51
|
+
"types": "./src/utils/index.ts"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
9
54
|
"repository": {
|
|
10
55
|
"type": "git",
|
|
11
56
|
"url": "git+https://github.com/aaronshaf/ger.git"
|
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
import { Effect, pipe, Schema
|
|
2
|
-
import {
|
|
3
|
-
ReviewStrategyService,
|
|
4
|
-
type ReviewStrategy,
|
|
5
|
-
ReviewStrategyError,
|
|
6
|
-
} from '@/services/review-strategy'
|
|
1
|
+
import { Effect, pipe, Schema } from 'effect'
|
|
2
|
+
import { ReviewStrategyService, ReviewStrategyError } from '@/services/review-strategy'
|
|
7
3
|
import { commentCommandWithInput } from './comment'
|
|
8
4
|
import { Console } from 'effect'
|
|
9
|
-
import {
|
|
10
|
-
import type { CommentInfo } from '@/schemas/gerrit'
|
|
11
|
-
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
12
|
-
import { formatDiffPretty } from '@/utils/diff-formatters'
|
|
13
|
-
import { formatDate } from '@/utils/formatters'
|
|
14
|
-
import {
|
|
15
|
-
formatChangeAsXML,
|
|
16
|
-
formatCommentsAsXML,
|
|
17
|
-
formatMessagesAsXML,
|
|
18
|
-
flattenComments,
|
|
19
|
-
} from '@/utils/review-formatters'
|
|
5
|
+
import { GerritApiService } from '@/api/gerrit'
|
|
20
6
|
import { buildEnhancedPrompt } from '@/utils/review-prompt-builder'
|
|
21
7
|
import * as fs from 'node:fs/promises'
|
|
22
8
|
import * as fsSync from 'node:fs'
|
|
@@ -25,7 +11,7 @@ import * as path from 'node:path'
|
|
|
25
11
|
import { fileURLToPath } from 'node:url'
|
|
26
12
|
import { dirname } from 'node:path'
|
|
27
13
|
import * as readline from 'node:readline'
|
|
28
|
-
import { GitWorktreeService
|
|
14
|
+
import { GitWorktreeService } from '@/services/git-worktree'
|
|
29
15
|
|
|
30
16
|
// Get the directory of this module
|
|
31
17
|
const __filename = fileURLToPath(import.meta.url)
|
|
@@ -126,7 +112,7 @@ const validateAndFixInlineComments = (
|
|
|
126
112
|
for (const rawComment of rawComments) {
|
|
127
113
|
// Validate comment structure using Effect Schema
|
|
128
114
|
const parseResult = yield* Schema.decodeUnknown(InlineCommentSchema)(rawComment).pipe(
|
|
129
|
-
Effect.catchTag('ParseError', (
|
|
115
|
+
Effect.catchTag('ParseError', (_parseError) =>
|
|
130
116
|
Effect.gen(function* () {
|
|
131
117
|
yield* Console.warn('Skipping comment with invalid structure')
|
|
132
118
|
return yield* Effect.succeed(null)
|
|
@@ -192,118 +178,6 @@ const validateAndFixInlineComments = (
|
|
|
192
178
|
return validComments
|
|
193
179
|
})
|
|
194
180
|
|
|
195
|
-
// Legacy helper for backward compatibility (will be removed)
|
|
196
|
-
const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
197
|
-
Effect.gen(function* () {
|
|
198
|
-
const gerritApi = yield* GerritApiService
|
|
199
|
-
|
|
200
|
-
// Fetch all data
|
|
201
|
-
const change = yield* gerritApi.getChange(changeId)
|
|
202
|
-
const diffResult = yield* gerritApi.getDiff(changeId)
|
|
203
|
-
const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
|
|
204
|
-
const commentsMap = yield* gerritApi.getComments(changeId)
|
|
205
|
-
const messages = yield* gerritApi.getMessages(changeId)
|
|
206
|
-
|
|
207
|
-
const comments = flattenComments(commentsMap)
|
|
208
|
-
|
|
209
|
-
// Build XML string using helper functions
|
|
210
|
-
const xmlLines: string[] = []
|
|
211
|
-
xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
212
|
-
xmlLines.push(`<show_result>`)
|
|
213
|
-
xmlLines.push(` <status>success</status>`)
|
|
214
|
-
xmlLines.push(...formatChangeAsXML(change))
|
|
215
|
-
xmlLines.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
|
|
216
|
-
xmlLines.push(...formatCommentsAsXML(comments))
|
|
217
|
-
xmlLines.push(...formatMessagesAsXML(messages))
|
|
218
|
-
xmlLines.push(`</show_result>`)
|
|
219
|
-
|
|
220
|
-
return xmlLines.join('\n')
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
// Helper to get change data and format as pretty string
|
|
224
|
-
const getChangeDataAsPretty = (
|
|
225
|
-
changeId: string,
|
|
226
|
-
): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
227
|
-
Effect.gen(function* () {
|
|
228
|
-
const gerritApi = yield* GerritApiService
|
|
229
|
-
|
|
230
|
-
// Fetch all data
|
|
231
|
-
const change = yield* gerritApi.getChange(changeId)
|
|
232
|
-
const diffResult = yield* gerritApi.getDiff(changeId)
|
|
233
|
-
const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
|
|
234
|
-
const commentsMap = yield* gerritApi.getComments(changeId)
|
|
235
|
-
const messages = yield* gerritApi.getMessages(changeId)
|
|
236
|
-
|
|
237
|
-
const comments = flattenComments(commentsMap)
|
|
238
|
-
|
|
239
|
-
// Build pretty string
|
|
240
|
-
const lines: string[] = []
|
|
241
|
-
|
|
242
|
-
// Change details header
|
|
243
|
-
lines.push('━'.repeat(80))
|
|
244
|
-
lines.push(`📋 Change ${change._number}: ${change.subject}`)
|
|
245
|
-
lines.push('━'.repeat(80))
|
|
246
|
-
lines.push('')
|
|
247
|
-
|
|
248
|
-
// Metadata
|
|
249
|
-
lines.push('📝 Details:')
|
|
250
|
-
lines.push(` Project: ${change.project}`)
|
|
251
|
-
lines.push(` Branch: ${change.branch}`)
|
|
252
|
-
lines.push(` Status: ${change.status}`)
|
|
253
|
-
lines.push(` Owner: ${change.owner?.name || change.owner?.email || 'Unknown'}`)
|
|
254
|
-
lines.push(` Created: ${change.created ? formatDate(change.created) : 'Unknown'}`)
|
|
255
|
-
lines.push(` Updated: ${change.updated ? formatDate(change.updated) : 'Unknown'}`)
|
|
256
|
-
lines.push(` Change-Id: ${change.change_id}`)
|
|
257
|
-
lines.push('')
|
|
258
|
-
|
|
259
|
-
// Diff section
|
|
260
|
-
lines.push('🔍 Diff:')
|
|
261
|
-
lines.push('─'.repeat(40))
|
|
262
|
-
lines.push(formatDiffPretty(diff))
|
|
263
|
-
lines.push('')
|
|
264
|
-
|
|
265
|
-
// Comments section
|
|
266
|
-
if (comments.length > 0) {
|
|
267
|
-
lines.push('💬 Inline Comments:')
|
|
268
|
-
lines.push('─'.repeat(40))
|
|
269
|
-
for (const comment of comments) {
|
|
270
|
-
const author = comment.author?.name || 'Unknown'
|
|
271
|
-
const date = comment.updated ? formatDate(comment.updated) : 'Unknown'
|
|
272
|
-
lines.push(`📅 ${date} - ${author}`)
|
|
273
|
-
if (comment.path) lines.push(` File: ${comment.path}`)
|
|
274
|
-
if (comment.line) lines.push(` Line: ${comment.line}`)
|
|
275
|
-
lines.push(` ${comment.message}`)
|
|
276
|
-
if (comment.unresolved) lines.push(` ⚠️ Unresolved`)
|
|
277
|
-
lines.push('')
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Messages section
|
|
282
|
-
if (messages.length > 0) {
|
|
283
|
-
lines.push('📝 Review Activity:')
|
|
284
|
-
lines.push('─'.repeat(40))
|
|
285
|
-
for (const message of messages) {
|
|
286
|
-
const author = message.author?.name || 'Unknown'
|
|
287
|
-
const date = formatDate(message.date)
|
|
288
|
-
const cleanMessage = message.message.trim()
|
|
289
|
-
|
|
290
|
-
// Skip very short automated messages
|
|
291
|
-
if (
|
|
292
|
-
cleanMessage.length < 10 &&
|
|
293
|
-
(cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
|
|
294
|
-
) {
|
|
295
|
-
continue
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
lines.push(`📅 ${date} - ${author}`)
|
|
299
|
-
lines.push(` ${cleanMessage}`)
|
|
300
|
-
lines.push('')
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return lines.join('\n')
|
|
305
|
-
})
|
|
306
|
-
|
|
307
181
|
// Helper function to prompt user for confirmation
|
|
308
182
|
const promptUser = (message: string): Effect.Effect<boolean, never> =>
|
|
309
183
|
Effect.async<boolean, never>((resume) => {
|
|
@@ -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 && {
|
package/src/cli/index.ts
CHANGED
|
@@ -56,7 +56,7 @@ function getVersion(): string {
|
|
|
56
56
|
const packageJsonPath = join(__dirname, '..', '..', 'package.json')
|
|
57
57
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
58
58
|
return packageJson.version || '0.0.0'
|
|
59
|
-
} catch
|
|
59
|
+
} catch {
|
|
60
60
|
// Fallback version if package.json can't be read
|
|
61
61
|
return '0.0.0'
|
|
62
62
|
}
|
|
@@ -84,7 +84,11 @@ export class NotGitRepoError
|
|
|
84
84
|
readonly name = 'NotGitRepoError'
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export type
|
|
87
|
+
export type GitWorktreeError =
|
|
88
|
+
| WorktreeCreationError
|
|
89
|
+
| PatchsetFetchError
|
|
90
|
+
| DirtyRepoError
|
|
91
|
+
| NotGitRepoError
|
|
88
92
|
|
|
89
93
|
// Worktree info
|
|
90
94
|
export interface WorktreeInfo {
|
|
@@ -99,8 +103,8 @@ export interface WorktreeInfo {
|
|
|
99
103
|
const runGitCommand = (
|
|
100
104
|
args: string[],
|
|
101
105
|
options: { cwd?: string } = {},
|
|
102
|
-
): Effect.Effect<string,
|
|
103
|
-
Effect.async<string,
|
|
106
|
+
): Effect.Effect<string, GitWorktreeError, never> =>
|
|
107
|
+
Effect.async<string, GitWorktreeError, never>((resume) => {
|
|
104
108
|
const child = spawn('git', args, {
|
|
105
109
|
cwd: options.cwd || process.cwd(),
|
|
106
110
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -156,23 +160,6 @@ const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
|
|
|
156
160
|
Effect.map(() => undefined),
|
|
157
161
|
)
|
|
158
162
|
|
|
159
|
-
// Check if working directory is clean
|
|
160
|
-
const validateCleanRepo = (): Effect.Effect<void, DirtyRepoError, never> =>
|
|
161
|
-
pipe(
|
|
162
|
-
runGitCommand(['status', '--porcelain']),
|
|
163
|
-
Effect.mapError(() => new DirtyRepoError({ message: 'Failed to check repository status' })),
|
|
164
|
-
Effect.flatMap((output) =>
|
|
165
|
-
output.trim() === ''
|
|
166
|
-
? Effect.succeed(undefined)
|
|
167
|
-
: Effect.fail(
|
|
168
|
-
new DirtyRepoError({
|
|
169
|
-
message:
|
|
170
|
-
'Working directory has uncommitted changes. Please commit or stash changes before review.',
|
|
171
|
-
}),
|
|
172
|
-
),
|
|
173
|
-
),
|
|
174
|
-
)
|
|
175
|
-
|
|
176
163
|
// Generate unique worktree path
|
|
177
164
|
const generateWorktreePath = (changeId: string): string => {
|
|
178
165
|
const timestamp = Date.now()
|
|
@@ -199,7 +186,7 @@ const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string
|
|
|
199
186
|
}
|
|
200
187
|
|
|
201
188
|
// Get the current HEAD commit hash to avoid branch conflicts
|
|
202
|
-
const getCurrentCommit = (): Effect.Effect<string,
|
|
189
|
+
const getCurrentCommit = (): Effect.Effect<string, GitWorktreeError, never> =>
|
|
203
190
|
pipe(
|
|
204
191
|
runGitCommand(['rev-parse', 'HEAD']),
|
|
205
192
|
Effect.map((output) => output.trim()),
|
|
@@ -243,11 +230,13 @@ const getLatestPatchsetNumber = (
|
|
|
243
230
|
|
|
244
231
|
// GitWorktreeService implementation
|
|
245
232
|
export interface GitWorktreeServiceImpl {
|
|
246
|
-
validatePreconditions: () => Effect.Effect<void,
|
|
247
|
-
createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo,
|
|
248
|
-
fetchAndCheckoutPatchset: (
|
|
233
|
+
validatePreconditions: () => Effect.Effect<void, GitWorktreeError, never>
|
|
234
|
+
createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitWorktreeError, never>
|
|
235
|
+
fetchAndCheckoutPatchset: (
|
|
236
|
+
worktreeInfo: WorktreeInfo,
|
|
237
|
+
) => Effect.Effect<void, GitWorktreeError, never>
|
|
249
238
|
cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never>
|
|
250
|
-
getChangedFiles: () => Effect.Effect<string[],
|
|
239
|
+
getChangedFiles: () => Effect.Effect<string[], GitWorktreeError, never>
|
|
251
240
|
}
|
|
252
241
|
|
|
253
242
|
const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for working with Gerrit
|
|
3
|
+
* @module utils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Change ID utilities
|
|
7
|
+
export {
|
|
8
|
+
normalizeChangeIdentifier,
|
|
9
|
+
isChangeId,
|
|
10
|
+
isChangeNumber,
|
|
11
|
+
isValidChangeIdentifier,
|
|
12
|
+
getIdentifierType,
|
|
13
|
+
} from './change-id'
|
|
14
|
+
|
|
15
|
+
// Git commit utilities
|
|
16
|
+
export {
|
|
17
|
+
extractChangeIdFromCommitMessage,
|
|
18
|
+
getLastCommitMessage,
|
|
19
|
+
getChangeIdFromHead,
|
|
20
|
+
GitError,
|
|
21
|
+
NoChangeIdError,
|
|
22
|
+
} from './git-commit'
|
|
23
|
+
|
|
24
|
+
// URL parsing
|
|
25
|
+
export {
|
|
26
|
+
extractChangeNumber,
|
|
27
|
+
normalizeGerritHost,
|
|
28
|
+
isValidChangeId,
|
|
29
|
+
} from './url-parser'
|
|
30
|
+
|
|
31
|
+
// Message filtering
|
|
32
|
+
export { filterMeaningfulMessages, sortMessagesByDate } from './message-filters'
|
|
33
|
+
|
|
34
|
+
// Shell safety
|
|
35
|
+
export { sanitizeCDATA } from './shell-safety'
|
|
36
|
+
|
|
37
|
+
// Formatters
|
|
38
|
+
export {
|
|
39
|
+
formatDate,
|
|
40
|
+
getStatusIndicator,
|
|
41
|
+
colors,
|
|
42
|
+
} from './formatters'
|
|
43
|
+
|
|
44
|
+
export {
|
|
45
|
+
formatCommentsPretty,
|
|
46
|
+
formatCommentsXml,
|
|
47
|
+
type CommentWithContext,
|
|
48
|
+
} from './comment-formatters'
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
formatDiffPretty,
|
|
52
|
+
formatDiffSummary,
|
|
53
|
+
formatFilesList,
|
|
54
|
+
extractDiffStats,
|
|
55
|
+
} from './diff-formatters'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { extractChangeNumber, isValidChangeId } from './url-parser'
|
|
2
|
+
import { extractChangeNumber, isValidChangeId, normalizeGerritHost } from './url-parser'
|
|
3
3
|
|
|
4
4
|
describe('extractChangeNumber', () => {
|
|
5
5
|
test('extracts change number from standard Gerrit URL', () => {
|
|
@@ -121,3 +121,151 @@ describe('isValidChangeId', () => {
|
|
|
121
121
|
expect(isValidChangeId('-abc')).toBe(false)
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('normalizeGerritHost', () => {
|
|
126
|
+
describe('adding protocol', () => {
|
|
127
|
+
test('adds https:// when no protocol is provided', () => {
|
|
128
|
+
expect(normalizeGerritHost('gerrit.example.com')).toBe('https://gerrit.example.com')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('adds https:// to hostname with port', () => {
|
|
132
|
+
expect(normalizeGerritHost('gerrit.example.com:8080')).toBe('https://gerrit.example.com:8080')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('adds https:// to localhost', () => {
|
|
136
|
+
expect(normalizeGerritHost('localhost:8080')).toBe('https://localhost:8080')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('adds https:// to IP address', () => {
|
|
140
|
+
expect(normalizeGerritHost('192.168.1.100')).toBe('https://192.168.1.100')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('adds https:// to IP address with port', () => {
|
|
144
|
+
expect(normalizeGerritHost('192.168.1.100:8080')).toBe('https://192.168.1.100:8080')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('preserving existing protocol', () => {
|
|
149
|
+
test('preserves https:// when already present', () => {
|
|
150
|
+
expect(normalizeGerritHost('https://gerrit.example.com')).toBe('https://gerrit.example.com')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('preserves http:// when explicitly provided', () => {
|
|
154
|
+
expect(normalizeGerritHost('http://gerrit.example.com')).toBe('http://gerrit.example.com')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('preserves https:// with port', () => {
|
|
158
|
+
expect(normalizeGerritHost('https://gerrit.example.com:8080')).toBe(
|
|
159
|
+
'https://gerrit.example.com:8080',
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('preserves http:// with port', () => {
|
|
164
|
+
expect(normalizeGerritHost('http://gerrit.example.com:8080')).toBe(
|
|
165
|
+
'http://gerrit.example.com:8080',
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('removing trailing slashes', () => {
|
|
171
|
+
test('removes single trailing slash', () => {
|
|
172
|
+
expect(normalizeGerritHost('https://gerrit.example.com/')).toBe('https://gerrit.example.com')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('removes trailing slash from URL without protocol', () => {
|
|
176
|
+
expect(normalizeGerritHost('gerrit.example.com/')).toBe('https://gerrit.example.com')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('removes trailing slash from URL with port', () => {
|
|
180
|
+
expect(normalizeGerritHost('https://gerrit.example.com:8080/')).toBe(
|
|
181
|
+
'https://gerrit.example.com:8080',
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('handles URL without trailing slash', () => {
|
|
186
|
+
expect(normalizeGerritHost('https://gerrit.example.com')).toBe('https://gerrit.example.com')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('does not remove slash from path', () => {
|
|
190
|
+
expect(normalizeGerritHost('https://gerrit.example.com/gerrit')).toBe(
|
|
191
|
+
'https://gerrit.example.com/gerrit',
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('removes trailing slash from path', () => {
|
|
196
|
+
expect(normalizeGerritHost('https://gerrit.example.com/gerrit/')).toBe(
|
|
197
|
+
'https://gerrit.example.com/gerrit',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('whitespace handling', () => {
|
|
203
|
+
test('trims leading whitespace', () => {
|
|
204
|
+
expect(normalizeGerritHost(' gerrit.example.com')).toBe('https://gerrit.example.com')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('trims trailing whitespace', () => {
|
|
208
|
+
expect(normalizeGerritHost('gerrit.example.com ')).toBe('https://gerrit.example.com')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('trims whitespace from URL with protocol', () => {
|
|
212
|
+
expect(normalizeGerritHost(' https://gerrit.example.com ')).toBe(
|
|
213
|
+
'https://gerrit.example.com',
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('trims whitespace and removes trailing slash', () => {
|
|
218
|
+
expect(normalizeGerritHost(' gerrit.example.com/ ')).toBe('https://gerrit.example.com')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('combined scenarios', () => {
|
|
223
|
+
test('adds protocol and removes trailing slash', () => {
|
|
224
|
+
expect(normalizeGerritHost('gerrit.example.com/')).toBe('https://gerrit.example.com')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('trims, adds protocol, and removes trailing slash', () => {
|
|
228
|
+
expect(normalizeGerritHost(' gerrit.example.com/ ')).toBe('https://gerrit.example.com')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('handles subdomain with port', () => {
|
|
232
|
+
expect(normalizeGerritHost('review.git.example.com:8443')).toBe(
|
|
233
|
+
'https://review.git.example.com:8443',
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('handles complex URL with path', () => {
|
|
238
|
+
expect(normalizeGerritHost('gerrit.example.com/gerrit')).toBe(
|
|
239
|
+
'https://gerrit.example.com/gerrit',
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('normalizes complete real-world example', () => {
|
|
244
|
+
expect(normalizeGerritHost('gerrit-review.example.org')).toBe(
|
|
245
|
+
'https://gerrit-review.example.org',
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('edge cases', () => {
|
|
251
|
+
test('handles empty string', () => {
|
|
252
|
+
// Empty string becomes 'https:/' after normalization (protocol added, then trailing slash removed)
|
|
253
|
+
expect(normalizeGerritHost('')).toBe('https:/')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('handles whitespace-only string', () => {
|
|
257
|
+
// Whitespace-only string becomes 'https:/' after normalization
|
|
258
|
+
expect(normalizeGerritHost(' ')).toBe('https:/')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('handles just a slash', () => {
|
|
262
|
+
// Just a slash becomes 'https://' (protocol added to '/', then trailing slash removed leaving '//')
|
|
263
|
+
expect(normalizeGerritHost('/')).toBe('https://')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('handles protocol only', () => {
|
|
267
|
+
// Protocol only becomes 'https:/' (trailing slash removed)
|
|
268
|
+
expect(normalizeGerritHost('https://')).toBe('https:/')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
})
|
package/src/utils/url-parser.ts
CHANGED
|
@@ -53,6 +53,33 @@ export const extractChangeNumber = (input: string): string => {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Normalizes a Gerrit host URL by adding https:// if no protocol is provided
|
|
58
|
+
* and removing trailing slashes
|
|
59
|
+
*
|
|
60
|
+
* @param host - The host URL to normalize (e.g., "gerrit.example.com" or "https://gerrit.example.com")
|
|
61
|
+
* @returns The normalized URL with protocol and without trailing slash
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* normalizeGerritHost("gerrit.example.com") // returns "https://gerrit.example.com"
|
|
65
|
+
* normalizeGerritHost("gerrit.example.com:8080") // returns "https://gerrit.example.com:8080"
|
|
66
|
+
* normalizeGerritHost("http://gerrit.example.com") // returns "http://gerrit.example.com"
|
|
67
|
+
* normalizeGerritHost("https://gerrit.example.com/") // returns "https://gerrit.example.com"
|
|
68
|
+
*/
|
|
69
|
+
export const normalizeGerritHost = (host: string): string => {
|
|
70
|
+
let normalized = host.trim()
|
|
71
|
+
|
|
72
|
+
// Add https:// if no protocol provided
|
|
73
|
+
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
|
|
74
|
+
normalized = `https://${normalized}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Remove trailing slash
|
|
78
|
+
normalized = normalized.replace(/\/$/, '')
|
|
79
|
+
|
|
80
|
+
return normalized
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
/**
|
|
57
84
|
* Validates if a string is a valid Gerrit change identifier
|
|
58
85
|
*
|
|
@@ -76,7 +76,7 @@ ${JSON.stringify(mockChange)}`)
|
|
|
76
76
|
return HttpResponse.text('Not Found', { status: 404 })
|
|
77
77
|
}),
|
|
78
78
|
|
|
79
|
-
http.post('*/a/changes/:changeId/revisions/current/review', async ({ params
|
|
79
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ params }) => {
|
|
80
80
|
const { changeId } = params
|
|
81
81
|
if (changeId === CHANGE_NUMBER || changeId === CHANGE_ID) {
|
|
82
82
|
return HttpResponse.text(`)]}'
|
package/tests/setup.test.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { normalizeGerritHost } from '@/utils/url-parser'
|
|
2
3
|
|
|
3
4
|
describe('Setup Command', () => {
|
|
4
|
-
describe('URL
|
|
5
|
-
test('should
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
expect(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
test('should handle URLs without trailing slashes', () => {
|
|
12
|
-
const url = 'https://gerrit.example.com'
|
|
13
|
-
const normalized = url.replace(/\/$/, '')
|
|
14
|
-
expect(normalized).toBe('https://gerrit.example.com')
|
|
5
|
+
describe('URL normalization integration', () => {
|
|
6
|
+
test('should normalize host URL using normalizeGerritHost', () => {
|
|
7
|
+
// Test that the utility function is working as expected
|
|
8
|
+
expect(normalizeGerritHost('gerrit.example.com')).toBe('https://gerrit.example.com')
|
|
9
|
+
expect(normalizeGerritHost('https://gerrit.example.com/')).toBe('https://gerrit.example.com')
|
|
10
|
+
expect(normalizeGerritHost('gerrit.example.com:8080')).toBe('https://gerrit.example.com:8080')
|
|
15
11
|
})
|
|
16
12
|
})
|
|
17
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
2
|
import { Effect, Layer } from 'effect'
|
|
3
|
-
import { GitWorktreeService
|
|
3
|
+
import { GitWorktreeService } from '@/services/git-worktree'
|
|
4
4
|
|
|
5
5
|
describe('Git Worktree Creation', () => {
|
|
6
6
|
test('should handle commit-based worktree creation in service interface', async () => {
|
|
@@ -11,7 +11,6 @@ describe('Git Worktree Creation', () => {
|
|
|
11
11
|
validatePreconditions: () => Effect.succeed(undefined),
|
|
12
12
|
createWorktree: (changeId: string) => {
|
|
13
13
|
// Simulate commit-based worktree creation (detached HEAD)
|
|
14
|
-
const currentCommit = 'abc123def456' // Mock commit hash
|
|
15
14
|
return Effect.succeed({
|
|
16
15
|
path: `/tmp/test-worktree-${changeId}`,
|
|
17
16
|
changeId,
|
|
@@ -100,7 +100,7 @@ describe('Review Strategy', () => {
|
|
|
100
100
|
}
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
-
mockChildProcess.stderr.on.mockImplementation((
|
|
103
|
+
mockChildProcess.stderr.on.mockImplementation((_event: string, _callback: Function) => {
|
|
104
104
|
// No stderr for success
|
|
105
105
|
})
|
|
106
106
|
|
|
@@ -112,7 +112,7 @@ describe('Review Strategy', () => {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
const setupFailedExecution = (exitCode = 1, stderr = 'Command failed') => {
|
|
115
|
-
mockChildProcess.stdout.on.mockImplementation((
|
|
115
|
+
mockChildProcess.stdout.on.mockImplementation((_event: string, _callback: Function) => {
|
|
116
116
|
// No stdout for failure
|
|
117
117
|
})
|
|
118
118
|
|