@aaronshaf/ger 0.1.11 → 0.2.1
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 +58 -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 -128
- package/src/cli/commands/setup.ts +3 -2
- package/src/cli/commands/show.ts +112 -18
- package/src/cli/index.ts +141 -24
- 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 -33
- 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/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 +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/setup.test.ts +7 -11
- package/tests/show-auto-detect.test.ts +306 -0
- package/tests/show.test.ts +157 -1
- package/tests/unit/git-branch-detection.test.ts +1 -2
- package/tests/unit/git-worktree.test.ts +2 -1
- package/tests/unit/services/review-strategy.test.ts +2 -2
- package/tsconfig.json +2 -1
|
@@ -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
|
+
}
|
|
28
69
|
|
|
29
|
-
export
|
|
70
|
+
export interface NotGitRepoErrorFields {
|
|
71
|
+
readonly message: string
|
|
72
|
+
}
|
|
73
|
+
|
|
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
|
|
|
@@ -102,23 +156,6 @@ const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
|
|
|
102
156
|
Effect.map(() => undefined),
|
|
103
157
|
)
|
|
104
158
|
|
|
105
|
-
// Check if working directory is clean
|
|
106
|
-
const validateCleanRepo = (): Effect.Effect<void, DirtyRepoError, never> =>
|
|
107
|
-
pipe(
|
|
108
|
-
runGitCommand(['status', '--porcelain']),
|
|
109
|
-
Effect.mapError(() => new DirtyRepoError({ message: 'Failed to check repository status' })),
|
|
110
|
-
Effect.flatMap((output) =>
|
|
111
|
-
output.trim() === ''
|
|
112
|
-
? Effect.succeed(undefined)
|
|
113
|
-
: Effect.fail(
|
|
114
|
-
new DirtyRepoError({
|
|
115
|
-
message:
|
|
116
|
-
'Working directory has uncommitted changes. Please commit or stash changes before review.',
|
|
117
|
-
}),
|
|
118
|
-
),
|
|
119
|
-
),
|
|
120
|
-
)
|
|
121
|
-
|
|
122
159
|
// Generate unique worktree path
|
|
123
160
|
const generateWorktreePath = (changeId: string): string => {
|
|
124
161
|
const timestamp = Date.now()
|
|
@@ -286,11 +323,14 @@ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
|
|
|
286
323
|
}),
|
|
287
324
|
}
|
|
288
325
|
|
|
289
|
-
// Export service tag for dependency injection
|
|
290
|
-
export
|
|
291
|
-
GitWorktreeService
|
|
292
|
-
GitWorktreeServiceImpl
|
|
293
|
-
>() {}
|
|
326
|
+
// Export service tag for dependency injection with explicit type
|
|
327
|
+
export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> =
|
|
328
|
+
Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService')
|
|
294
329
|
|
|
295
|
-
|
|
296
|
-
|
|
330
|
+
export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService>
|
|
331
|
+
|
|
332
|
+
// Export service layer with explicit type
|
|
333
|
+
export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed(
|
|
334
|
+
GitWorktreeService,
|
|
335
|
+
GitWorktreeServiceImplLive,
|
|
336
|
+
)
|
|
@@ -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
|
+
})
|