@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.
@@ -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,6 +1,5 @@
1
1
  import { Effect } from 'effect'
2
2
  import { type ApiError, GerritApiService } from '@/api/gerrit'
3
- import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
4
3
  import { flattenComments } from '@/utils/review-formatters'
5
4
 
6
5
  export const buildEnhancedPrompt = (
@@ -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
+ })
@@ -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
+ })