@aaronshaf/ger 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/claude-code-review.yml +61 -56
- package/.github/workflows/claude.yml +10 -24
- package/README.md +53 -6
- package/bun.lock +8 -8
- package/package.json +3 -3
- package/src/api/gerrit.ts +54 -16
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/review.ts +13 -2
- package/src/cli/commands/setup.ts +1 -1
- package/src/cli/commands/show.ts +112 -18
- package/src/cli/index.ts +140 -23
- package/src/schemas/config.ts +13 -4
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +60 -16
- package/src/services/git-worktree.ts +73 -16
- package/src/services/review-strategy.ts +40 -22
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/extract-url.test.ts +518 -0
- package/tests/mocks/fetch-mock.ts +5 -2
- package/tests/mocks/msw-handlers.ts +3 -3
- package/tests/show-auto-detect.test.ts +306 -0
- package/tests/show.test.ts +157 -1
- package/tests/unit/git-worktree.test.ts +2 -1
- package/tsconfig.json +2 -1
package/src/cli/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { abandonCommand } from './commands/abandon'
|
|
|
34
34
|
import { commentCommand } from './commands/comment'
|
|
35
35
|
import { commentsCommand } from './commands/comments'
|
|
36
36
|
import { diffCommand } from './commands/diff'
|
|
37
|
+
import { extractUrlCommand } from './commands/extract-url'
|
|
37
38
|
import { incomingCommand } from './commands/incoming'
|
|
38
39
|
import { mineCommand } from './commands/mine'
|
|
39
40
|
import { openCommand } from './commands/open'
|
|
@@ -42,6 +43,7 @@ import { setup } from './commands/setup'
|
|
|
42
43
|
import { showCommand } from './commands/show'
|
|
43
44
|
import { statusCommand } from './commands/status'
|
|
44
45
|
import { workspaceCommand } from './commands/workspace'
|
|
46
|
+
import { sanitizeCDATA } from '@/utils/shell-safety'
|
|
45
47
|
|
|
46
48
|
// Read version from package.json
|
|
47
49
|
function getVersion(): string {
|
|
@@ -101,7 +103,7 @@ program
|
|
|
101
103
|
// comment command
|
|
102
104
|
program
|
|
103
105
|
.command('comment <change-id>')
|
|
104
|
-
.description('Post a comment on a change')
|
|
106
|
+
.description('Post a comment on a change (accepts change number or Change-ID)')
|
|
105
107
|
.option('-m, --message <message>', 'Comment message')
|
|
106
108
|
.option('--file <file>', 'File path for line-specific comment (relative to repo root)')
|
|
107
109
|
.option(
|
|
@@ -116,9 +118,12 @@ program
|
|
|
116
118
|
'after',
|
|
117
119
|
`
|
|
118
120
|
Examples:
|
|
119
|
-
# Post a general comment on a change
|
|
121
|
+
# Post a general comment on a change (using change number)
|
|
120
122
|
$ ger comment 12345 -m "Looks good to me!"
|
|
121
123
|
|
|
124
|
+
# Post a comment using Change-ID
|
|
125
|
+
$ ger comment If5a3ae8cb5a107e187447802358417f311d0c4b1 -m "LGTM"
|
|
126
|
+
|
|
122
127
|
# Post a comment using piped input (useful for multi-line comments or scripts)
|
|
123
128
|
$ echo "This is a comment from stdin!" | ger comment 12345
|
|
124
129
|
$ cat review-notes.txt | ger comment 12345
|
|
@@ -135,9 +140,11 @@ Examples:
|
|
|
135
140
|
{"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
|
|
136
141
|
]}' | ger comment 12345 --batch
|
|
137
142
|
|
|
138
|
-
Note:
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
Note:
|
|
144
|
+
- Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
|
|
145
|
+
- Line numbers refer to the actual line numbers in the NEW version of the file,
|
|
146
|
+
NOT the line numbers shown in the diff view. To find the correct line number,
|
|
147
|
+
look at the file after all changes have been applied.`,
|
|
141
148
|
)
|
|
142
149
|
.action(async (changeId, options) => {
|
|
143
150
|
try {
|
|
@@ -165,7 +172,7 @@ Note: Line numbers refer to the actual line numbers in the NEW version of the fi
|
|
|
165
172
|
// diff command
|
|
166
173
|
program
|
|
167
174
|
.command('diff <change-id>')
|
|
168
|
-
.description('Get diff for a change')
|
|
175
|
+
.description('Get diff for a change (accepts change number or Change-ID)')
|
|
169
176
|
.option('--xml', 'XML output for LLM consumption')
|
|
170
177
|
.option('--file <file>', 'Specific file to diff')
|
|
171
178
|
.option('--files-only', 'List changed files only')
|
|
@@ -224,7 +231,9 @@ program
|
|
|
224
231
|
// workspace command
|
|
225
232
|
program
|
|
226
233
|
.command('workspace <change-id>')
|
|
227
|
-
.description(
|
|
234
|
+
.description(
|
|
235
|
+
'Create or switch to a git worktree for a Gerrit change (accepts change number or Change-ID)',
|
|
236
|
+
)
|
|
228
237
|
.option('--xml', 'XML output for LLM consumption')
|
|
229
238
|
.action(async (changeId, options) => {
|
|
230
239
|
try {
|
|
@@ -281,7 +290,9 @@ program
|
|
|
281
290
|
// abandon command
|
|
282
291
|
program
|
|
283
292
|
.command('abandon [change-id]')
|
|
284
|
-
.description(
|
|
293
|
+
.description(
|
|
294
|
+
'Abandon a change (interactive mode if no change-id provided; accepts change number or Change-ID)',
|
|
295
|
+
)
|
|
285
296
|
.option('-m, --message <message>', 'Abandon message')
|
|
286
297
|
.option('--xml', 'XML output for LLM consumption')
|
|
287
298
|
.action(async (changeId, options) => {
|
|
@@ -310,7 +321,9 @@ program
|
|
|
310
321
|
// comments command
|
|
311
322
|
program
|
|
312
323
|
.command('comments <change-id>')
|
|
313
|
-
.description(
|
|
324
|
+
.description(
|
|
325
|
+
'Show all comments on a change with diff context (accepts change number or Change-ID)',
|
|
326
|
+
)
|
|
314
327
|
.option('--xml', 'XML output for LLM consumption')
|
|
315
328
|
.action(async (changeId, options) => {
|
|
316
329
|
try {
|
|
@@ -338,7 +351,7 @@ program
|
|
|
338
351
|
// open command
|
|
339
352
|
program
|
|
340
353
|
.command('open <change-id>')
|
|
341
|
-
.description('Open a change in the browser')
|
|
354
|
+
.description('Open a change in the browser (accepts change number or Change-ID)')
|
|
342
355
|
.action(async (changeId, options) => {
|
|
343
356
|
try {
|
|
344
357
|
const effect = openCommand(changeId, options).pipe(
|
|
@@ -354,9 +367,34 @@ program
|
|
|
354
367
|
|
|
355
368
|
// show command
|
|
356
369
|
program
|
|
357
|
-
.command('show
|
|
358
|
-
.description(
|
|
370
|
+
.command('show [change-id]')
|
|
371
|
+
.description(
|
|
372
|
+
'Show comprehensive change information (auto-detects from HEAD commit if not specified)',
|
|
373
|
+
)
|
|
359
374
|
.option('--xml', 'XML output for LLM consumption')
|
|
375
|
+
.option('--json', 'JSON output for programmatic consumption')
|
|
376
|
+
.addHelpText(
|
|
377
|
+
'after',
|
|
378
|
+
`
|
|
379
|
+
Examples:
|
|
380
|
+
# Show specific change (using change number)
|
|
381
|
+
$ ger show 392385
|
|
382
|
+
|
|
383
|
+
# Show specific change (using Change-ID)
|
|
384
|
+
$ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
385
|
+
|
|
386
|
+
# Auto-detect Change-ID from HEAD commit
|
|
387
|
+
$ ger show
|
|
388
|
+
$ ger show --xml
|
|
389
|
+
$ ger show --json
|
|
390
|
+
|
|
391
|
+
# Extract build failure URL with jq
|
|
392
|
+
$ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
|
|
393
|
+
|
|
394
|
+
Note: When no change-id is provided, it will be automatically extracted from the
|
|
395
|
+
Change-ID footer in your HEAD commit. You must be in a git repository with
|
|
396
|
+
a commit that has a Change-ID.`,
|
|
397
|
+
)
|
|
360
398
|
.action(async (changeId, options) => {
|
|
361
399
|
try {
|
|
362
400
|
const effect = showCommand(changeId, options).pipe(
|
|
@@ -365,16 +403,88 @@ program
|
|
|
365
403
|
)
|
|
366
404
|
await Effect.runPromise(effect)
|
|
367
405
|
} catch (error) {
|
|
368
|
-
|
|
406
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
407
|
+
if (options.json) {
|
|
408
|
+
console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
|
|
409
|
+
} else if (options.xml) {
|
|
369
410
|
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
370
411
|
console.log(`<show_result>`)
|
|
371
412
|
console.log(` <status>error</status>`)
|
|
372
|
-
console.log(
|
|
373
|
-
` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
|
|
374
|
-
)
|
|
413
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
|
|
375
414
|
console.log(`</show_result>`)
|
|
376
415
|
} else {
|
|
377
|
-
console.error('✗ Error:',
|
|
416
|
+
console.error('✗ Error:', errorMessage)
|
|
417
|
+
}
|
|
418
|
+
process.exit(1)
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// extract-url command
|
|
423
|
+
program
|
|
424
|
+
.command('extract-url <pattern> [change-id]')
|
|
425
|
+
.description(
|
|
426
|
+
'Extract URLs from change messages and comments (auto-detects from HEAD commit if not specified)',
|
|
427
|
+
)
|
|
428
|
+
.option('--include-comments', 'Also search inline comments (default: messages only)')
|
|
429
|
+
.option('--regex', 'Treat pattern as regex instead of substring match')
|
|
430
|
+
.option('--xml', 'XML output for LLM consumption')
|
|
431
|
+
.option('--json', 'JSON output for programmatic consumption')
|
|
432
|
+
.addHelpText(
|
|
433
|
+
'after',
|
|
434
|
+
`
|
|
435
|
+
Examples:
|
|
436
|
+
# Extract all Jenkins build-summary-report URLs (substring match)
|
|
437
|
+
$ ger extract-url "build-summary-report"
|
|
438
|
+
|
|
439
|
+
# Get the latest build URL using tail
|
|
440
|
+
$ ger extract-url "build-summary-report" | tail -1
|
|
441
|
+
|
|
442
|
+
# Get the first build URL using head
|
|
443
|
+
$ ger extract-url "jenkins.inst-ci.net" | head -1
|
|
444
|
+
|
|
445
|
+
# For a specific change (using change number)
|
|
446
|
+
$ ger extract-url "build-summary" 391831
|
|
447
|
+
|
|
448
|
+
# For a specific change (using Change-ID)
|
|
449
|
+
$ ger extract-url "jenkins" If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
450
|
+
|
|
451
|
+
# Use regex for precise matching
|
|
452
|
+
$ ger extract-url "job/Canvas/job/main/\\d+/" --regex
|
|
453
|
+
|
|
454
|
+
# Search both messages and inline comments
|
|
455
|
+
$ ger extract-url "github.com" --include-comments
|
|
456
|
+
|
|
457
|
+
# JSON output for scripting
|
|
458
|
+
$ ger extract-url "jenkins" --json | jq -r '.urls[-1]'
|
|
459
|
+
|
|
460
|
+
# XML output
|
|
461
|
+
$ ger extract-url "jenkins" --xml
|
|
462
|
+
|
|
463
|
+
Note:
|
|
464
|
+
- URLs are output in chronological order (oldest first)
|
|
465
|
+
- Use tail -1 to get the latest URL, head -1 for the oldest
|
|
466
|
+
- When no change-id is provided, it will be automatically extracted from the
|
|
467
|
+
Change-ID footer in your HEAD commit`,
|
|
468
|
+
)
|
|
469
|
+
.action(async (pattern, changeId, options) => {
|
|
470
|
+
try {
|
|
471
|
+
const effect = extractUrlCommand(pattern, changeId, options).pipe(
|
|
472
|
+
Effect.provide(GerritApiServiceLive),
|
|
473
|
+
Effect.provide(ConfigServiceLive),
|
|
474
|
+
)
|
|
475
|
+
await Effect.runPromise(effect)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
478
|
+
if (options.json) {
|
|
479
|
+
console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
|
|
480
|
+
} else if (options.xml) {
|
|
481
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
482
|
+
console.log(`<extract_url_result>`)
|
|
483
|
+
console.log(` <status>error</status>`)
|
|
484
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
|
|
485
|
+
console.log(`</extract_url_result>`)
|
|
486
|
+
} else {
|
|
487
|
+
console.error('✗ Error:', errorMessage)
|
|
378
488
|
}
|
|
379
489
|
process.exit(1)
|
|
380
490
|
}
|
|
@@ -383,7 +493,9 @@ program
|
|
|
383
493
|
// review command
|
|
384
494
|
program
|
|
385
495
|
.command('review <change-id>')
|
|
386
|
-
.description(
|
|
496
|
+
.description(
|
|
497
|
+
'AI-powered code review that analyzes changes and optionally posts comments (accepts change number or Change-ID)',
|
|
498
|
+
)
|
|
387
499
|
.option('--comment', 'Post the review as comments (prompts for confirmation)')
|
|
388
500
|
.option('-y, --yes', 'Skip confirmation prompts when posting comments')
|
|
389
501
|
.option('--debug', 'Show debug output including AI responses')
|
|
@@ -408,20 +520,25 @@ Requirements:
|
|
|
408
520
|
- Gerrit credentials must be configured (run 'ger setup' first)
|
|
409
521
|
|
|
410
522
|
Examples:
|
|
411
|
-
# Review a change (display only)
|
|
523
|
+
# Review a change using change number (display only)
|
|
412
524
|
$ ger review 12345
|
|
413
|
-
|
|
525
|
+
|
|
526
|
+
# Review using Change-ID
|
|
527
|
+
$ ger review If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
528
|
+
|
|
414
529
|
# Review and prompt to post comments
|
|
415
530
|
$ ger review 12345 --comment
|
|
416
|
-
|
|
531
|
+
|
|
417
532
|
# Review and auto-post comments without prompting
|
|
418
533
|
$ ger review 12345 --comment --yes
|
|
419
|
-
|
|
534
|
+
|
|
420
535
|
# Use specific AI tool
|
|
421
536
|
$ ger review 12345 --tool gemini
|
|
422
|
-
|
|
537
|
+
|
|
423
538
|
# Show debug output to troubleshoot issues
|
|
424
539
|
$ ger review 12345 --debug
|
|
540
|
+
|
|
541
|
+
Note: Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
|
|
425
542
|
`,
|
|
426
543
|
)
|
|
427
544
|
.action(async (changeId, options) => {
|
package/src/schemas/config.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Schema } from '@effect/schema'
|
|
2
2
|
|
|
3
3
|
// Flat Application Configuration (similar to ji structure)
|
|
4
|
-
export const AppConfig
|
|
4
|
+
export const AppConfig: Schema.Struct<{
|
|
5
|
+
host: typeof Schema.String
|
|
6
|
+
username: typeof Schema.String
|
|
7
|
+
password: typeof Schema.String
|
|
8
|
+
aiTool: Schema.optional<Schema.Literal<['claude', 'llm', 'opencode', 'gemini']>>
|
|
9
|
+
aiAutoDetect: Schema.optionalWith<typeof Schema.Boolean, { default: () => boolean }>
|
|
10
|
+
}> = Schema.Struct({
|
|
5
11
|
// Gerrit credentials (flattened)
|
|
6
12
|
host: Schema.String.pipe(
|
|
7
13
|
Schema.pattern(/^https?:\/\/.+$/),
|
|
@@ -17,15 +23,18 @@ export const AppConfig = Schema.Struct({
|
|
|
17
23
|
),
|
|
18
24
|
// AI configuration (flattened)
|
|
19
25
|
aiTool: Schema.optional(Schema.Literal('claude', 'llm', 'opencode', 'gemini')),
|
|
20
|
-
aiAutoDetect: Schema.optionalWith(Schema.Boolean, { default: () => true }),
|
|
26
|
+
aiAutoDetect: Schema.optionalWith(Schema.Boolean, { default: (): boolean => true }),
|
|
21
27
|
})
|
|
22
28
|
|
|
23
29
|
export type AppConfig = Schema.Schema.Type<typeof AppConfig>
|
|
24
30
|
|
|
25
31
|
// Legacy schemas for backward compatibility (deprecated)
|
|
26
|
-
export const AiConfig
|
|
32
|
+
export const AiConfig: Schema.Struct<{
|
|
33
|
+
tool: Schema.optional<Schema.Literal<['claude', 'llm', 'opencode', 'gemini']>>
|
|
34
|
+
autoDetect: Schema.optionalWith<typeof Schema.Boolean, { default: () => boolean }>
|
|
35
|
+
}> = Schema.Struct({
|
|
27
36
|
tool: Schema.optional(Schema.Literal('claude', 'llm', 'opencode', 'gemini')),
|
|
28
|
-
autoDetect: Schema.optionalWith(Schema.Boolean, { default: () => true }),
|
|
37
|
+
autoDetect: Schema.optionalWith(Schema.Boolean, { default: (): boolean => true }),
|
|
29
38
|
})
|
|
30
39
|
|
|
31
40
|
export type AiConfig = Schema.Schema.Type<typeof AiConfig>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
import { ConfigService, ConfigServiceLive } from './config'
|
|
7
|
+
|
|
8
|
+
describe('ConfigService', () => {
|
|
9
|
+
let originalEnv: NodeJS.ProcessEnv
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.ger')
|
|
11
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
12
|
+
let originalConfigContent: string | null = null
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Store original env vars
|
|
16
|
+
originalEnv = { ...process.env }
|
|
17
|
+
|
|
18
|
+
// Clear environment variables for clean tests
|
|
19
|
+
delete process.env.GERRIT_HOST
|
|
20
|
+
delete process.env.GERRIT_USERNAME
|
|
21
|
+
delete process.env.GERRIT_PASSWORD
|
|
22
|
+
|
|
23
|
+
// Backup and remove existing config file for clean tests
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
26
|
+
originalConfigContent = fs.readFileSync(CONFIG_FILE, 'utf8')
|
|
27
|
+
fs.unlinkSync(CONFIG_FILE)
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Ignore errors
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
// Restore original env vars
|
|
36
|
+
process.env = originalEnv
|
|
37
|
+
|
|
38
|
+
// Restore original config file
|
|
39
|
+
try {
|
|
40
|
+
if (originalConfigContent !== null) {
|
|
41
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(CONFIG_FILE, originalConfigContent, 'utf8')
|
|
45
|
+
fs.chmodSync(CONFIG_FILE, 0o600)
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore errors
|
|
49
|
+
}
|
|
50
|
+
originalConfigContent = null
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('Environment Variable Configuration', () => {
|
|
54
|
+
test('loads config from environment variables when all required vars are present', async () => {
|
|
55
|
+
// Set environment variables
|
|
56
|
+
process.env.GERRIT_HOST = 'https://gerrit.example.com'
|
|
57
|
+
process.env.GERRIT_USERNAME = 'envuser'
|
|
58
|
+
process.env.GERRIT_PASSWORD = 'envpass123'
|
|
59
|
+
|
|
60
|
+
const result = await Effect.gen(function* () {
|
|
61
|
+
const configService = yield* ConfigService
|
|
62
|
+
return yield* configService.getFullConfig
|
|
63
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
host: 'https://gerrit.example.com',
|
|
67
|
+
username: 'envuser',
|
|
68
|
+
password: 'envpass123',
|
|
69
|
+
aiAutoDetect: true,
|
|
70
|
+
aiTool: undefined,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('loads credentials from environment variables', async () => {
|
|
75
|
+
// Set environment variables
|
|
76
|
+
process.env.GERRIT_HOST = 'https://gerrit.example.com'
|
|
77
|
+
process.env.GERRIT_USERNAME = 'envuser'
|
|
78
|
+
process.env.GERRIT_PASSWORD = 'envpass123'
|
|
79
|
+
|
|
80
|
+
const result = await Effect.gen(function* () {
|
|
81
|
+
const configService = yield* ConfigService
|
|
82
|
+
return yield* configService.getCredentials
|
|
83
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual({
|
|
86
|
+
host: 'https://gerrit.example.com',
|
|
87
|
+
username: 'envuser',
|
|
88
|
+
password: 'envpass123',
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('fails when only some environment variables are present', async () => {
|
|
93
|
+
// Set only some environment variables
|
|
94
|
+
process.env.GERRIT_HOST = 'https://gerrit.example.com'
|
|
95
|
+
process.env.GERRIT_USERNAME = 'envuser'
|
|
96
|
+
// GERRIT_PASSWORD is missing
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const configService = yield* ConfigService
|
|
101
|
+
return yield* configService.getFullConfig
|
|
102
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
|
|
103
|
+
).rejects.toThrow('Configuration not found')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('validates environment variable configuration format', async () => {
|
|
107
|
+
// Set invalid environment variables
|
|
108
|
+
process.env.GERRIT_HOST = 'not-a-url'
|
|
109
|
+
process.env.GERRIT_USERNAME = 'envuser'
|
|
110
|
+
process.env.GERRIT_PASSWORD = 'envpass123'
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const configService = yield* ConfigService
|
|
115
|
+
return yield* configService.getFullConfig
|
|
116
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
|
|
117
|
+
).rejects.toThrow('Invalid environment configuration format')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('rejects empty environment variables', async () => {
|
|
121
|
+
// Set empty environment variables
|
|
122
|
+
process.env.GERRIT_HOST = 'https://gerrit.example.com'
|
|
123
|
+
process.env.GERRIT_USERNAME = ''
|
|
124
|
+
process.env.GERRIT_PASSWORD = 'envpass123'
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const configService = yield* ConfigService
|
|
129
|
+
return yield* configService.getFullConfig
|
|
130
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
|
|
131
|
+
).rejects.toThrow('Configuration not found')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('provides helpful error message when no configuration is found', async () => {
|
|
135
|
+
// Clear all relevant environment variables
|
|
136
|
+
delete process.env.GERRIT_HOST
|
|
137
|
+
delete process.env.GERRIT_USERNAME
|
|
138
|
+
delete process.env.GERRIT_PASSWORD
|
|
139
|
+
|
|
140
|
+
await expect(
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const configService = yield* ConfigService
|
|
143
|
+
return yield* configService.getFullConfig
|
|
144
|
+
}).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
|
|
145
|
+
).rejects.toThrow(
|
|
146
|
+
'Configuration not found. Run "ger setup" to set up your credentials or set GERRIT_HOST, GERRIT_USERNAME, and GERRIT_PASSWORD environment variables.',
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
package/src/services/config.ts
CHANGED
|
@@ -16,19 +16,50 @@ export interface ConfigServiceImpl {
|
|
|
16
16
|
readonly saveFullConfig: (config: AppConfig) => Effect.Effect<void, ConfigError>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ConfigServiceImpl
|
|
22
|
-
>
|
|
19
|
+
// Export both the tag value and the type for use in Effect requirements
|
|
20
|
+
export const ConfigService: Context.Tag<ConfigServiceImpl, ConfigServiceImpl> =
|
|
21
|
+
Context.GenericTag<ConfigServiceImpl>('ConfigService')
|
|
22
|
+
export type ConfigService = Context.Tag.Identifier<typeof ConfigService>
|
|
23
|
+
|
|
24
|
+
// Export ConfigError fields interface explicitly
|
|
25
|
+
export interface ConfigErrorFields {
|
|
26
|
+
readonly message: string
|
|
27
|
+
}
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
// Define error schema (not exported, so type can be implicit)
|
|
30
|
+
const ConfigErrorSchema = Schema.TaggedError<ConfigErrorFields>()('ConfigError', {
|
|
25
31
|
message: Schema.String,
|
|
26
|
-
} as const)
|
|
32
|
+
} as const) as unknown
|
|
33
|
+
|
|
34
|
+
// Export the error class with explicit constructor signature for isolatedDeclarations
|
|
35
|
+
export class ConfigError
|
|
36
|
+
extends (ConfigErrorSchema as new (
|
|
37
|
+
args: ConfigErrorFields,
|
|
38
|
+
) => ConfigErrorFields & Error & { readonly _tag: 'ConfigError' })
|
|
39
|
+
implements Error
|
|
40
|
+
{
|
|
41
|
+
readonly name = 'ConfigError'
|
|
42
|
+
}
|
|
27
43
|
|
|
28
44
|
// File-based storage
|
|
29
45
|
const CONFIG_DIR = path.join(os.homedir(), '.ger')
|
|
30
46
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
31
47
|
|
|
48
|
+
const readEnvConfig = (): unknown | null => {
|
|
49
|
+
const { GERRIT_HOST, GERRIT_USERNAME, GERRIT_PASSWORD } = process.env
|
|
50
|
+
|
|
51
|
+
if (GERRIT_HOST && GERRIT_USERNAME && GERRIT_PASSWORD) {
|
|
52
|
+
return {
|
|
53
|
+
host: GERRIT_HOST,
|
|
54
|
+
username: GERRIT_USERNAME,
|
|
55
|
+
password: GERRIT_PASSWORD,
|
|
56
|
+
aiAutoDetect: true,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
const readFileConfig = (): unknown | null => {
|
|
33
64
|
try {
|
|
34
65
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
@@ -85,21 +116,34 @@ export const ConfigServiceLive: Layer.Layer<ConfigService, never, never> = Layer
|
|
|
85
116
|
ConfigService,
|
|
86
117
|
Effect.sync(() => {
|
|
87
118
|
const getFullConfig = Effect.gen(function* () {
|
|
119
|
+
// First try to read from file
|
|
88
120
|
const fileContent = readFileConfig()
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}),
|
|
121
|
+
if (fileContent) {
|
|
122
|
+
// Parse as flat config
|
|
123
|
+
const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(fileContent).pipe(
|
|
124
|
+
Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
|
|
94
125
|
)
|
|
126
|
+
return fullConfigResult
|
|
95
127
|
}
|
|
96
128
|
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
// Fallback to environment variables
|
|
130
|
+
const envContent = readEnvConfig()
|
|
131
|
+
if (envContent) {
|
|
132
|
+
const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(envContent).pipe(
|
|
133
|
+
Effect.mapError(
|
|
134
|
+
() => new ConfigError({ message: 'Invalid environment configuration format' }),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
return fullConfigResult
|
|
138
|
+
}
|
|
101
139
|
|
|
102
|
-
|
|
140
|
+
// No configuration found
|
|
141
|
+
return yield* Effect.fail(
|
|
142
|
+
new ConfigError({
|
|
143
|
+
message:
|
|
144
|
+
'Configuration not found. Run "ger setup" to set up your credentials or set GERRIT_HOST, GERRIT_USERNAME, and GERRIT_PASSWORD environment variables.',
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
103
147
|
})
|
|
104
148
|
|
|
105
149
|
const saveFullConfig = (config: AppConfig) =>
|
|
@@ -5,30 +5,84 @@ import * as path from 'node:path'
|
|
|
5
5
|
import * as fs from 'node:fs/promises'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
|
|
8
|
-
// Error types
|
|
9
|
-
export
|
|
8
|
+
// Error types with explicit interfaces
|
|
9
|
+
export interface WorktreeCreationErrorFields {
|
|
10
|
+
readonly message: string
|
|
11
|
+
readonly cause?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WorktreeCreationErrorSchema = Schema.TaggedError<WorktreeCreationErrorFields>()(
|
|
10
15
|
'WorktreeCreationError',
|
|
11
16
|
{
|
|
12
17
|
message: Schema.String,
|
|
13
18
|
cause: Schema.optional(Schema.Unknown),
|
|
14
19
|
},
|
|
15
|
-
)
|
|
20
|
+
) as unknown
|
|
21
|
+
|
|
22
|
+
export class WorktreeCreationError
|
|
23
|
+
extends (WorktreeCreationErrorSchema as new (
|
|
24
|
+
args: WorktreeCreationErrorFields,
|
|
25
|
+
) => WorktreeCreationErrorFields & Error & { readonly _tag: 'WorktreeCreationError' })
|
|
26
|
+
implements Error
|
|
27
|
+
{
|
|
28
|
+
readonly name = 'WorktreeCreationError'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PatchsetFetchErrorFields {
|
|
32
|
+
readonly message: string
|
|
33
|
+
readonly cause?: unknown
|
|
34
|
+
}
|
|
16
35
|
|
|
17
|
-
|
|
36
|
+
const PatchsetFetchErrorSchema = Schema.TaggedError<PatchsetFetchErrorFields>()(
|
|
18
37
|
'PatchsetFetchError',
|
|
19
38
|
{
|
|
20
39
|
message: Schema.String,
|
|
21
40
|
cause: Schema.optional(Schema.Unknown),
|
|
22
41
|
},
|
|
23
|
-
)
|
|
42
|
+
) as unknown
|
|
43
|
+
|
|
44
|
+
export class PatchsetFetchError
|
|
45
|
+
extends (PatchsetFetchErrorSchema as new (
|
|
46
|
+
args: PatchsetFetchErrorFields,
|
|
47
|
+
) => PatchsetFetchErrorFields & Error & { readonly _tag: 'PatchsetFetchError' })
|
|
48
|
+
implements Error
|
|
49
|
+
{
|
|
50
|
+
readonly name = 'PatchsetFetchError'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DirtyRepoErrorFields {
|
|
54
|
+
readonly message: string
|
|
55
|
+
}
|
|
24
56
|
|
|
25
|
-
|
|
57
|
+
const DirtyRepoErrorSchema = Schema.TaggedError<DirtyRepoErrorFields>()('DirtyRepoError', {
|
|
26
58
|
message: Schema.String,
|
|
27
|
-
})
|
|
59
|
+
}) as unknown
|
|
60
|
+
|
|
61
|
+
export class DirtyRepoError
|
|
62
|
+
extends (DirtyRepoErrorSchema as new (
|
|
63
|
+
args: DirtyRepoErrorFields,
|
|
64
|
+
) => DirtyRepoErrorFields & Error & { readonly _tag: 'DirtyRepoError' })
|
|
65
|
+
implements Error
|
|
66
|
+
{
|
|
67
|
+
readonly name = 'DirtyRepoError'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface NotGitRepoErrorFields {
|
|
71
|
+
readonly message: string
|
|
72
|
+
}
|
|
28
73
|
|
|
29
|
-
|
|
74
|
+
const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
|
|
30
75
|
message: Schema.String,
|
|
31
|
-
})
|
|
76
|
+
}) as unknown
|
|
77
|
+
|
|
78
|
+
export class NotGitRepoError
|
|
79
|
+
extends (NotGitRepoErrorSchema as new (
|
|
80
|
+
args: NotGitRepoErrorFields,
|
|
81
|
+
) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' })
|
|
82
|
+
implements Error
|
|
83
|
+
{
|
|
84
|
+
readonly name = 'NotGitRepoError'
|
|
85
|
+
}
|
|
32
86
|
|
|
33
87
|
export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
|
|
34
88
|
|
|
@@ -286,11 +340,14 @@ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
|
|
|
286
340
|
}),
|
|
287
341
|
}
|
|
288
342
|
|
|
289
|
-
// Export service tag for dependency injection
|
|
290
|
-
export
|
|
291
|
-
GitWorktreeService
|
|
292
|
-
|
|
293
|
-
>
|
|
343
|
+
// Export service tag for dependency injection with explicit type
|
|
344
|
+
export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> =
|
|
345
|
+
Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService')
|
|
346
|
+
|
|
347
|
+
export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService>
|
|
294
348
|
|
|
295
|
-
// Export service layer
|
|
296
|
-
export const GitWorktreeServiceLive = Layer.succeed(
|
|
349
|
+
// Export service layer with explicit type
|
|
350
|
+
export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed(
|
|
351
|
+
GitWorktreeService,
|
|
352
|
+
GitWorktreeServiceImplLive,
|
|
353
|
+
)
|