@aaronshaf/ger 0.1.11 → 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 +51 -0
- package/bun.lock +8 -8
- package/package.json +3 -3
- package/src/api/gerrit.ts +54 -16
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/review.ts +13 -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
|
@@ -12,10 +12,23 @@ const extractResponse = (stdout: string): string => {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// Simple strategy focused only on review needs
|
|
15
|
-
export
|
|
16
|
-
message: string
|
|
17
|
-
cause?: unknown
|
|
18
|
-
}
|
|
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
|
+
}
|
|
19
32
|
|
|
20
33
|
// Review strategy interface - focused on specific review patterns
|
|
21
34
|
export interface ReviewStrategy {
|
|
@@ -202,25 +215,30 @@ export const openCodeCliStrategy: ReviewStrategy = {
|
|
|
202
215
|
}),
|
|
203
216
|
}
|
|
204
217
|
|
|
205
|
-
// Review service using strategy pattern
|
|
206
|
-
export
|
|
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(
|
|
207
240
|
ReviewStrategyService,
|
|
208
241
|
{
|
|
209
|
-
readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
|
|
210
|
-
readonly selectStrategy: (
|
|
211
|
-
preferredName?: string,
|
|
212
|
-
) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
|
|
213
|
-
readonly executeWithStrategy: (
|
|
214
|
-
strategy: ReviewStrategy,
|
|
215
|
-
prompt: string,
|
|
216
|
-
options?: { cwd?: string; systemPrompt?: string },
|
|
217
|
-
) => Effect.Effect<string, ReviewStrategyError>
|
|
218
|
-
}
|
|
219
|
-
>() {}
|
|
220
|
-
|
|
221
|
-
export const ReviewStrategyServiceLive = Layer.succeed(
|
|
222
|
-
ReviewStrategyService,
|
|
223
|
-
ReviewStrategyService.of({
|
|
224
242
|
getAvailableStrategies: () =>
|
|
225
243
|
Effect.gen(function* () {
|
|
226
244
|
const strategies = [claudeCliStrategy, geminiCliStrategy, openCodeCliStrategy]
|
|
@@ -270,5 +288,5 @@ export const ReviewStrategyServiceLive = Layer.succeed(
|
|
|
270
288
|
|
|
271
289
|
executeWithStrategy: (strategy, prompt, options = {}) =>
|
|
272
290
|
strategy.executeReview(prompt, options),
|
|
273
|
-
}
|
|
291
|
+
},
|
|
274
292
|
)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
extractChangeIdFromCommitMessage,
|
|
5
|
+
getLastCommitMessage,
|
|
6
|
+
getChangeIdFromHead,
|
|
7
|
+
} from './git-commit'
|
|
8
|
+
import * as childProcess from 'node:child_process'
|
|
9
|
+
import { EventEmitter } from 'node:events'
|
|
10
|
+
|
|
11
|
+
let spawnSpy: ReturnType<typeof spyOn>
|
|
12
|
+
|
|
13
|
+
describe('git-commit utilities', () => {
|
|
14
|
+
describe('extractChangeIdFromCommitMessage', () => {
|
|
15
|
+
test('extracts Change-ID from typical commit message', () => {
|
|
16
|
+
const message = `feat: add new feature
|
|
17
|
+
|
|
18
|
+
This is a longer description of the feature.
|
|
19
|
+
|
|
20
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
21
|
+
|
|
22
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
23
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('extracts Change-ID with extra whitespace', () => {
|
|
28
|
+
const message = `fix: bug fix
|
|
29
|
+
|
|
30
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1 `
|
|
31
|
+
|
|
32
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
33
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('extracts Change-ID from minimal commit', () => {
|
|
38
|
+
const message = `Change-Id: I0123456789abcdef0123456789abcdef01234567`
|
|
39
|
+
|
|
40
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
41
|
+
'I0123456789abcdef0123456789abcdef01234567',
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('extracts first Change-ID when multiple exist', () => {
|
|
46
|
+
const message = `feat: feature
|
|
47
|
+
|
|
48
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
49
|
+
Change-Id: I1111111111111111111111111111111111111111`
|
|
50
|
+
|
|
51
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
52
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('returns null when no Change-ID present', () => {
|
|
57
|
+
const message = `feat: add feature
|
|
58
|
+
|
|
59
|
+
This commit has no Change-ID footer.`
|
|
60
|
+
|
|
61
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(null)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns null for empty message', () => {
|
|
65
|
+
expect(extractChangeIdFromCommitMessage('')).toBe(null)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('ignores Change-ID in commit body (not footer)', () => {
|
|
69
|
+
const message = `feat: update
|
|
70
|
+
|
|
71
|
+
This mentions Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1 in body
|
|
72
|
+
but it's not in the footer.
|
|
73
|
+
|
|
74
|
+
Signed-off-by: User`
|
|
75
|
+
|
|
76
|
+
// Should not match because it's not at the start of a line (footer position)
|
|
77
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(null)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('handles Change-ID with lowercase hex digits', () => {
|
|
81
|
+
const message = `Change-Id: Iabcdef0123456789abcdef0123456789abcdef01`
|
|
82
|
+
|
|
83
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
84
|
+
'Iabcdef0123456789abcdef0123456789abcdef01',
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('returns null for malformed Change-ID (too short)', () => {
|
|
89
|
+
const message = `Change-Id: If5a3ae8cb5a107e187447`
|
|
90
|
+
|
|
91
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(null)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('returns null for malformed Change-ID (too long)', () => {
|
|
95
|
+
const message = `Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b11111`
|
|
96
|
+
|
|
97
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(null)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('returns null for Change-ID not starting with I', () => {
|
|
101
|
+
const message = `Change-Id: Gf5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
102
|
+
|
|
103
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(null)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('handles CRLF line endings', () => {
|
|
107
|
+
const message = `feat: feature\r\n\r\nChange-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1\r\n`
|
|
108
|
+
|
|
109
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
110
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('is case-insensitive for "Change-Id" label', () => {
|
|
115
|
+
const message = `change-id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
116
|
+
|
|
117
|
+
expect(extractChangeIdFromCommitMessage(message)).toBe(
|
|
118
|
+
'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('getLastCommitMessage', () => {
|
|
124
|
+
let mockChildProcess: EventEmitter
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
mockChildProcess = new EventEmitter()
|
|
128
|
+
// @ts-ignore - adding missing properties for mock
|
|
129
|
+
mockChildProcess.stdout = new EventEmitter()
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
mockChildProcess.stderr = new EventEmitter()
|
|
132
|
+
|
|
133
|
+
spawnSpy = spyOn(childProcess, 'spawn')
|
|
134
|
+
spawnSpy.mockReturnValue(mockChildProcess as any)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
spawnSpy.mockRestore()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('returns commit message on success', async () => {
|
|
142
|
+
const commitMessage = `feat: add feature
|
|
143
|
+
|
|
144
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
145
|
+
|
|
146
|
+
const effect = getLastCommitMessage()
|
|
147
|
+
|
|
148
|
+
const resultPromise = Effect.runPromise(effect)
|
|
149
|
+
|
|
150
|
+
// Simulate git command success
|
|
151
|
+
setImmediate(() => {
|
|
152
|
+
// @ts-ignore
|
|
153
|
+
mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
|
|
154
|
+
mockChildProcess.emit('close', 0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const result = await resultPromise
|
|
158
|
+
expect(result).toBe(commitMessage)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('throws GitError when not in git repository', async () => {
|
|
162
|
+
const effect = getLastCommitMessage()
|
|
163
|
+
|
|
164
|
+
const resultPromise = Effect.runPromise(effect)
|
|
165
|
+
|
|
166
|
+
setImmediate(() => {
|
|
167
|
+
// @ts-ignore
|
|
168
|
+
mockChildProcess.stderr.emit('data', Buffer.from('fatal: not a git repository'))
|
|
169
|
+
mockChildProcess.emit('close', 128)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await resultPromise
|
|
174
|
+
expect(true).toBe(false) // Should not reach here
|
|
175
|
+
} catch (error: any) {
|
|
176
|
+
expect(error.message).toContain('fatal: not a git repository')
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('throws GitError on spawn error', async () => {
|
|
181
|
+
const effect = getLastCommitMessage()
|
|
182
|
+
|
|
183
|
+
const resultPromise = Effect.runPromise(effect)
|
|
184
|
+
|
|
185
|
+
setImmediate(() => {
|
|
186
|
+
mockChildProcess.emit('error', new Error('ENOENT: git not found'))
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await resultPromise
|
|
191
|
+
expect(true).toBe(false) // Should not reach here
|
|
192
|
+
} catch (error: any) {
|
|
193
|
+
expect(error.message).toContain('Failed to execute git command')
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('getChangeIdFromHead', () => {
|
|
199
|
+
let mockChildProcess: EventEmitter
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
mockChildProcess = new EventEmitter()
|
|
203
|
+
// @ts-ignore
|
|
204
|
+
mockChildProcess.stdout = new EventEmitter()
|
|
205
|
+
// @ts-ignore
|
|
206
|
+
mockChildProcess.stderr = new EventEmitter()
|
|
207
|
+
|
|
208
|
+
spawnSpy = spyOn(childProcess, 'spawn')
|
|
209
|
+
spawnSpy.mockReturnValue(mockChildProcess as any)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
spawnSpy.mockRestore()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('returns Change-ID from HEAD commit', async () => {
|
|
217
|
+
const commitMessage = `feat: add feature
|
|
218
|
+
|
|
219
|
+
Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
|
|
220
|
+
|
|
221
|
+
const effect = getChangeIdFromHead()
|
|
222
|
+
|
|
223
|
+
const resultPromise = Effect.runPromise(effect)
|
|
224
|
+
|
|
225
|
+
setImmediate(() => {
|
|
226
|
+
// @ts-ignore
|
|
227
|
+
mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
|
|
228
|
+
mockChildProcess.emit('close', 0)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const result = await resultPromise
|
|
232
|
+
expect(result).toBe('If5a3ae8cb5a107e187447802358417f311d0c4b1')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('throws NoChangeIdError when commit has no Change-ID', async () => {
|
|
236
|
+
const commitMessage = `feat: add feature
|
|
237
|
+
|
|
238
|
+
This commit has no Change-ID.`
|
|
239
|
+
|
|
240
|
+
const effect = getChangeIdFromHead()
|
|
241
|
+
|
|
242
|
+
const resultPromise = Effect.runPromise(effect)
|
|
243
|
+
|
|
244
|
+
setImmediate(() => {
|
|
245
|
+
// @ts-ignore
|
|
246
|
+
mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
|
|
247
|
+
mockChildProcess.emit('close', 0)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await resultPromise
|
|
252
|
+
expect(true).toBe(false) // Should not reach here
|
|
253
|
+
} catch (error: any) {
|
|
254
|
+
expect(error.message).toContain('No Change-ID found in HEAD commit')
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('throws GitError when not in git repository', async () => {
|
|
259
|
+
const effect = getChangeIdFromHead()
|
|
260
|
+
|
|
261
|
+
const resultPromise = Effect.runPromise(effect)
|
|
262
|
+
|
|
263
|
+
setImmediate(() => {
|
|
264
|
+
// @ts-ignore
|
|
265
|
+
mockChildProcess.stderr.emit('data', Buffer.from('fatal: not a git repository'))
|
|
266
|
+
mockChildProcess.emit('close', 128)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await resultPromise
|
|
271
|
+
expect(true).toBe(false) // Should not reach here
|
|
272
|
+
} catch (error: any) {
|
|
273
|
+
expect(error.message).toContain('fatal: not a git repository')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Error thrown when git operations fail
|
|
6
|
+
*/
|
|
7
|
+
export class GitError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly cause?: unknown,
|
|
11
|
+
) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = 'GitError'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error thrown when no Change-ID is found in commit message
|
|
19
|
+
*/
|
|
20
|
+
export class NoChangeIdError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'NoChangeIdError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extracts the Change-ID from a git commit message.
|
|
29
|
+
* Gerrit adds Change-ID as a footer line in the format: "Change-Id: I<40-char-hash>"
|
|
30
|
+
*
|
|
31
|
+
* @param message - The full commit message
|
|
32
|
+
* @returns The Change-ID if found, null otherwise
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* const msg = "feat: add feature\n\nChange-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1"
|
|
37
|
+
* extractChangeIdFromCommitMessage(msg) // "If5a3ae8cb5a107e187447802358417f311d0c4b1"
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function extractChangeIdFromCommitMessage(message: string): string | null {
|
|
41
|
+
// Match "Change-Id: I<40-hex-chars>" in commit footer
|
|
42
|
+
// Case-insensitive, allows whitespace, multiline mode
|
|
43
|
+
const changeIdRegex = /^Change-Id:\s*(I[0-9a-f]{40})\s*$/im
|
|
44
|
+
|
|
45
|
+
const match = message.match(changeIdRegex)
|
|
46
|
+
return match ? match[1] : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Runs a git command and returns the output
|
|
51
|
+
*/
|
|
52
|
+
const runGitCommand = (args: readonly string[]): Effect.Effect<string, GitError> =>
|
|
53
|
+
Effect.async<string, GitError>((resume) => {
|
|
54
|
+
const child = spawn('git', [...args], {
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
let stdout = ''
|
|
59
|
+
let stderr = ''
|
|
60
|
+
|
|
61
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
62
|
+
stdout += data.toString()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
66
|
+
stderr += data.toString()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
child.on('error', (error: Error) => {
|
|
70
|
+
resume(Effect.fail(new GitError('Failed to execute git command', error)))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
child.on('close', (code: number | null) => {
|
|
74
|
+
if (code === 0) {
|
|
75
|
+
resume(Effect.succeed(stdout.trim()))
|
|
76
|
+
} else {
|
|
77
|
+
const errorMessage =
|
|
78
|
+
stderr.trim() || `Git command failed with exit code ${code ?? 'unknown'}`
|
|
79
|
+
resume(Effect.fail(new GitError(errorMessage)))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gets the commit message of the HEAD commit
|
|
86
|
+
*
|
|
87
|
+
* @returns Effect that resolves to the commit message
|
|
88
|
+
* @throws GitError if not in a git repository or git command fails
|
|
89
|
+
*/
|
|
90
|
+
export const getLastCommitMessage = (): Effect.Effect<string, GitError> =>
|
|
91
|
+
runGitCommand(['log', '-1', '--pretty=format:%B'])
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extracts the Change-ID from the HEAD commit message
|
|
95
|
+
*
|
|
96
|
+
* @returns Effect that resolves to the Change-ID
|
|
97
|
+
* @throws GitError if not in a git repository or git command fails
|
|
98
|
+
* @throws NoChangeIdError if no Change-ID is found in the commit message
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const effect = getChangeIdFromHead()
|
|
103
|
+
* const changeId = await Effect.runPromise(effect)
|
|
104
|
+
* console.log(changeId) // "If5a3ae8cb5a107e187447802358417f311d0c4b1"
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export const getChangeIdFromHead = (): Effect.Effect<string, GitError | NoChangeIdError> =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const message = yield* getLastCommitMessage()
|
|
110
|
+
|
|
111
|
+
const changeId = extractChangeIdFromCommitMessage(message)
|
|
112
|
+
|
|
113
|
+
if (!changeId) {
|
|
114
|
+
return yield* Effect.fail(
|
|
115
|
+
new NoChangeIdError(
|
|
116
|
+
'No Change-ID found in HEAD commit. Please provide a change number or Change-ID explicitly.',
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return changeId
|
|
122
|
+
})
|