@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
|
@@ -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
|
+
})
|
|
@@ -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
|
*
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'
|
|
2
|
+
import { setupServer } from 'msw/node'
|
|
3
|
+
import { http, HttpResponse } from 'msw'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { showCommand } from '@/cli/commands/show'
|
|
6
|
+
import { commentCommand } from '@/cli/commands/comment'
|
|
7
|
+
import { diffCommand } from '@/cli/commands/diff'
|
|
8
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
9
|
+
import { ConfigService } from '@/services/config'
|
|
10
|
+
import { generateMockChange } from '@/test-utils/mock-generator'
|
|
11
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Integration tests to verify that commands accept both change number and Change-ID formats
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const CHANGE_NUMBER = '392385'
|
|
18
|
+
const CHANGE_ID = 'If5a3ae8cb5a107e187447802358417f311d0c4b1'
|
|
19
|
+
|
|
20
|
+
const mockChange = generateMockChange({
|
|
21
|
+
_number: 392385,
|
|
22
|
+
change_id: CHANGE_ID,
|
|
23
|
+
subject: 'WIP: test',
|
|
24
|
+
status: 'NEW',
|
|
25
|
+
project: 'canvas-lms',
|
|
26
|
+
branch: 'master',
|
|
27
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
28
|
+
updated: '2024-01-15 12:00:00.000000000',
|
|
29
|
+
owner: {
|
|
30
|
+
_account_id: 1001,
|
|
31
|
+
name: 'Test User',
|
|
32
|
+
email: 'test@example.com',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const mockDiff = `--- a/test.txt
|
|
37
|
+
+++ b/test.txt
|
|
38
|
+
@@ -1,1 +1,2 @@
|
|
39
|
+
original line
|
|
40
|
+
+new line`
|
|
41
|
+
|
|
42
|
+
const server = setupServer(
|
|
43
|
+
http.get('*/a/accounts/self', () => {
|
|
44
|
+
return HttpResponse.json({
|
|
45
|
+
_account_id: 1000,
|
|
46
|
+
name: 'Test User',
|
|
47
|
+
email: 'test@example.com',
|
|
48
|
+
})
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
// Handler that matches both change number and Change-ID
|
|
52
|
+
http.get('*/a/changes/:changeId', ({ params }) => {
|
|
53
|
+
const { changeId } = params
|
|
54
|
+
// Accept both formats
|
|
55
|
+
if (changeId === CHANGE_NUMBER || changeId === CHANGE_ID) {
|
|
56
|
+
return HttpResponse.text(`)]}'
|
|
57
|
+
${JSON.stringify(mockChange)}`)
|
|
58
|
+
}
|
|
59
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
http.get('*/a/changes/:changeId/revisions/current/patch', ({ params }) => {
|
|
63
|
+
const { changeId } = params
|
|
64
|
+
if (changeId === CHANGE_NUMBER || changeId === CHANGE_ID) {
|
|
65
|
+
return HttpResponse.text(btoa(mockDiff))
|
|
66
|
+
}
|
|
67
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
http.get('*/a/changes/:changeId/revisions/current/comments', ({ params }) => {
|
|
71
|
+
const { changeId } = params
|
|
72
|
+
if (changeId === CHANGE_NUMBER || changeId === CHANGE_ID) {
|
|
73
|
+
return HttpResponse.text(`)]}'
|
|
74
|
+
{}`)
|
|
75
|
+
}
|
|
76
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ params }) => {
|
|
80
|
+
const { changeId } = params
|
|
81
|
+
if (changeId === CHANGE_NUMBER || changeId === CHANGE_ID) {
|
|
82
|
+
return HttpResponse.text(`)]}'
|
|
83
|
+
{}`)
|
|
84
|
+
}
|
|
85
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
let capturedLogs: string[] = []
|
|
90
|
+
let capturedErrors: string[] = []
|
|
91
|
+
|
|
92
|
+
const mockConsoleLog = mock((...args: any[]) => {
|
|
93
|
+
capturedLogs.push(args.join(' '))
|
|
94
|
+
})
|
|
95
|
+
const mockConsoleError = mock((...args: any[]) => {
|
|
96
|
+
capturedErrors.push(args.join(' '))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const originalConsoleLog = console.log
|
|
100
|
+
const originalConsoleError = console.error
|
|
101
|
+
|
|
102
|
+
beforeAll(() => {
|
|
103
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
104
|
+
// @ts-ignore
|
|
105
|
+
console.log = mockConsoleLog
|
|
106
|
+
// @ts-ignore
|
|
107
|
+
console.error = mockConsoleError
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
afterAll(() => {
|
|
111
|
+
server.close()
|
|
112
|
+
console.log = originalConsoleLog
|
|
113
|
+
console.error = originalConsoleError
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
server.resetHandlers()
|
|
118
|
+
mockConsoleLog.mockClear()
|
|
119
|
+
mockConsoleError.mockClear()
|
|
120
|
+
capturedLogs = []
|
|
121
|
+
capturedErrors = []
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const createMockConfigLayer = (): Layer.Layer<ConfigService, never, never> =>
|
|
125
|
+
Layer.succeed(ConfigService, createMockConfigService())
|
|
126
|
+
|
|
127
|
+
describe('Change ID format support', () => {
|
|
128
|
+
describe('show command', () => {
|
|
129
|
+
test('accepts numeric change number', async () => {
|
|
130
|
+
const effect = showCommand(CHANGE_NUMBER, {}).pipe(
|
|
131
|
+
Effect.provide(GerritApiServiceLive),
|
|
132
|
+
Effect.provide(createMockConfigLayer()),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await Effect.runPromise(effect)
|
|
136
|
+
|
|
137
|
+
const output = capturedLogs.join('\n')
|
|
138
|
+
expect(output).toContain('Change 392385')
|
|
139
|
+
expect(output).toContain('WIP: test')
|
|
140
|
+
expect(capturedErrors.length).toBe(0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('accepts Change-ID format', async () => {
|
|
144
|
+
const effect = showCommand(CHANGE_ID, {}).pipe(
|
|
145
|
+
Effect.provide(GerritApiServiceLive),
|
|
146
|
+
Effect.provide(createMockConfigLayer()),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await Effect.runPromise(effect)
|
|
150
|
+
|
|
151
|
+
const output = capturedLogs.join('\n')
|
|
152
|
+
expect(output).toContain('Change 392385')
|
|
153
|
+
expect(output).toContain('WIP: test')
|
|
154
|
+
expect(capturedErrors.length).toBe(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('rejects invalid change identifier', async () => {
|
|
158
|
+
const effect = showCommand('invalid-id', {}).pipe(
|
|
159
|
+
Effect.provide(GerritApiServiceLive),
|
|
160
|
+
Effect.provide(createMockConfigLayer()),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await Effect.runPromise(effect)
|
|
164
|
+
|
|
165
|
+
const output = capturedErrors.join('\n')
|
|
166
|
+
expect(output).toContain('Invalid change identifier')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('diff command', () => {
|
|
171
|
+
test('accepts numeric change number', async () => {
|
|
172
|
+
const effect = diffCommand(CHANGE_NUMBER, {}).pipe(
|
|
173
|
+
Effect.provide(GerritApiServiceLive),
|
|
174
|
+
Effect.provide(createMockConfigLayer()),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
await Effect.runPromise(effect)
|
|
178
|
+
|
|
179
|
+
const output = capturedLogs.join('\n')
|
|
180
|
+
expect(output).toContain('--- a/test.txt')
|
|
181
|
+
expect(output).toContain('+++ b/test.txt')
|
|
182
|
+
expect(capturedErrors.length).toBe(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('accepts Change-ID format', async () => {
|
|
186
|
+
const effect = diffCommand(CHANGE_ID, {}).pipe(
|
|
187
|
+
Effect.provide(GerritApiServiceLive),
|
|
188
|
+
Effect.provide(createMockConfigLayer()),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
await Effect.runPromise(effect)
|
|
192
|
+
|
|
193
|
+
const output = capturedLogs.join('\n')
|
|
194
|
+
expect(output).toContain('--- a/test.txt')
|
|
195
|
+
expect(output).toContain('+++ b/test.txt')
|
|
196
|
+
expect(capturedErrors.length).toBe(0)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('comment command', () => {
|
|
201
|
+
test('accepts numeric change number', async () => {
|
|
202
|
+
const effect = commentCommand(CHANGE_NUMBER, { message: 'LGTM' }).pipe(
|
|
203
|
+
Effect.provide(GerritApiServiceLive),
|
|
204
|
+
Effect.provide(createMockConfigLayer()),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
await Effect.runPromise(effect)
|
|
208
|
+
|
|
209
|
+
const output = capturedLogs.join('\n')
|
|
210
|
+
expect(output).toContain('Comment posted successfully')
|
|
211
|
+
expect(capturedErrors.length).toBe(0)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('accepts Change-ID format', async () => {
|
|
215
|
+
const effect = commentCommand(CHANGE_ID, { message: 'LGTM' }).pipe(
|
|
216
|
+
Effect.provide(GerritApiServiceLive),
|
|
217
|
+
Effect.provide(createMockConfigLayer()),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await Effect.runPromise(effect)
|
|
221
|
+
|
|
222
|
+
const output = capturedLogs.join('\n')
|
|
223
|
+
expect(output).toContain('Comment posted successfully')
|
|
224
|
+
expect(capturedErrors.length).toBe(0)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('edge cases', () => {
|
|
229
|
+
test('trims whitespace from change identifiers', async () => {
|
|
230
|
+
const effect = showCommand(` ${CHANGE_NUMBER} `, {}).pipe(
|
|
231
|
+
Effect.provide(GerritApiServiceLive),
|
|
232
|
+
Effect.provide(createMockConfigLayer()),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
await Effect.runPromise(effect)
|
|
236
|
+
|
|
237
|
+
const output = capturedLogs.join('\n')
|
|
238
|
+
expect(output).toContain('Change 392385')
|
|
239
|
+
expect(capturedErrors.length).toBe(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('validates Change-ID format strictly (uppercase I)', async () => {
|
|
243
|
+
const lowercaseChangeId = 'if5a3ae8cb5a107e187447802358417f311d0c4b1'
|
|
244
|
+
const effect = showCommand(lowercaseChangeId, {}).pipe(
|
|
245
|
+
Effect.provide(GerritApiServiceLive),
|
|
246
|
+
Effect.provide(createMockConfigLayer()),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
await Effect.runPromise(effect)
|
|
250
|
+
|
|
251
|
+
const output = capturedErrors.join('\n')
|
|
252
|
+
expect(output).toContain('Invalid change identifier')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('rejects Change-ID with incorrect length', async () => {
|
|
256
|
+
const shortChangeId = 'If5a3ae8cb5a107e18744780235841'
|
|
257
|
+
const effect = showCommand(shortChangeId, {}).pipe(
|
|
258
|
+
Effect.provide(GerritApiServiceLive),
|
|
259
|
+
Effect.provide(createMockConfigLayer()),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
await Effect.runPromise(effect)
|
|
263
|
+
|
|
264
|
+
const output = capturedErrors.join('\n')
|
|
265
|
+
expect(output).toContain('Invalid change identifier')
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
})
|