@aaronshaf/ger 1.2.10 → 2.0.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.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -180
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -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
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Utility functions for working with Gerrit
3
+ * @module utils
4
+ */
5
+
6
+ // Change ID utilities
7
+ export {
8
+ normalizeChangeIdentifier,
9
+ isChangeId,
10
+ isChangeNumber,
11
+ isValidChangeIdentifier,
12
+ getIdentifierType,
13
+ } from './change-id'
14
+
15
+ // Git commit utilities
16
+ export {
17
+ extractChangeIdFromCommitMessage,
18
+ getLastCommitMessage,
19
+ getChangeIdFromHead,
20
+ GitError,
21
+ NoChangeIdError,
22
+ } from './git-commit'
23
+
24
+ // URL parsing
25
+ export {
26
+ extractChangeNumber,
27
+ normalizeGerritHost,
28
+ isValidChangeId,
29
+ } from './url-parser'
30
+
31
+ // Message filtering
32
+ export { filterMeaningfulMessages, sortMessagesByDate } from './message-filters'
33
+
34
+ // Shell safety
35
+ export { sanitizeCDATA } from './shell-safety'
36
+
37
+ // Formatters
38
+ export {
39
+ formatDate,
40
+ getStatusIndicator,
41
+ colors,
42
+ } from './formatters'
43
+
44
+ export {
45
+ formatCommentsPretty,
46
+ formatCommentsXml,
47
+ type CommentWithContext,
48
+ } from './comment-formatters'
49
+
50
+ export {
51
+ formatDiffPretty,
52
+ formatDiffSummary,
53
+ formatFilesList,
54
+ extractDiffStats,
55
+ } from './diff-formatters'
@@ -0,0 +1,26 @@
1
+ import type { MessageInfo } from '@/schemas/gerrit'
2
+
3
+ /**
4
+ * Filters out automated messages and empty messages, keeping meaningful review activity
5
+ */
6
+ export const filterMeaningfulMessages = (messages: readonly MessageInfo[]): MessageInfo[] => {
7
+ return messages.filter((msg) => {
8
+ // Keep messages that have content beyond automated tags
9
+ if (!msg.message || msg.message.trim().length === 0) return false
10
+
11
+ // Skip some automated messages but keep build/review status messages
12
+ if (msg.tag === 'autogenerated:gerrit:newPatchSet') return false
13
+ if (msg.tag === 'autogenerated:gerrit:merged') return false
14
+
15
+ return true
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Sorts messages by date with newest first
21
+ */
22
+ export const sortMessagesByDate = (messages: readonly MessageInfo[]): MessageInfo[] => {
23
+ return [...messages].sort((a, b) => {
24
+ return new Date(b.date).getTime() - new Date(a.date).getTime()
25
+ })
26
+ }
@@ -0,0 +1,89 @@
1
+ import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
2
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
3
+
4
+ export const formatChangeAsXML = (change: ChangeInfo): string[] => {
5
+ const lines: string[] = []
6
+ lines.push(` <change>`)
7
+ lines.push(` <id>${escapeXML(change.change_id)}</id>`)
8
+ lines.push(` <number>${change._number}</number>`)
9
+ lines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
10
+ lines.push(` <status>${escapeXML(change.status)}</status>`)
11
+ lines.push(` <project>${escapeXML(change.project)}</project>`)
12
+ lines.push(` <branch>${escapeXML(change.branch)}</branch>`)
13
+ lines.push(` <owner>`)
14
+ if (change.owner?.name) {
15
+ lines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
16
+ }
17
+ if (change.owner?.email) {
18
+ lines.push(` <email>${escapeXML(change.owner.email)}</email>`)
19
+ }
20
+ lines.push(` </owner>`)
21
+ lines.push(` <created>${escapeXML(change.created || '')}</created>`)
22
+ lines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
23
+ lines.push(` </change>`)
24
+ return lines
25
+ }
26
+
27
+ export const formatCommentsAsXML = (comments: readonly CommentInfo[]): string[] => {
28
+ const lines: string[] = []
29
+ lines.push(` <comments>`)
30
+ lines.push(` <count>${comments.length}</count>`)
31
+ for (const comment of comments) {
32
+ lines.push(` <comment>`)
33
+ if (comment.id) lines.push(` <id>${escapeXML(comment.id)}</id>`)
34
+ if (comment.path) {
35
+ lines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
36
+ }
37
+ if (comment.line) lines.push(` <line>${comment.line}</line>`)
38
+ if (comment.author?.name) {
39
+ lines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
40
+ }
41
+ if (comment.updated) lines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
42
+ if (comment.message) {
43
+ lines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
44
+ }
45
+ if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
46
+ lines.push(` </comment>`)
47
+ }
48
+ lines.push(` </comments>`)
49
+ return lines
50
+ }
51
+
52
+ export const formatMessagesAsXML = (messages: readonly MessageInfo[]): string[] => {
53
+ const lines: string[] = []
54
+ lines.push(` <messages>`)
55
+ lines.push(` <count>${messages.length}</count>`)
56
+ for (const message of messages) {
57
+ lines.push(` <message>`)
58
+ lines.push(` <id>${escapeXML(message.id)}</id>`)
59
+ if (message.author?.name) {
60
+ lines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
61
+ }
62
+ if (message.author?._account_id) {
63
+ lines.push(` <author_id>${message.author._account_id}</author_id>`)
64
+ }
65
+ lines.push(` <date>${escapeXML(message.date)}</date>`)
66
+ if (message._revision_number) {
67
+ lines.push(` <revision>${message._revision_number}</revision>`)
68
+ }
69
+ if (message.tag) {
70
+ lines.push(` <tag>${escapeXML(message.tag)}</tag>`)
71
+ }
72
+ lines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
73
+ lines.push(` </message>`)
74
+ }
75
+ lines.push(` </messages>`)
76
+ return lines
77
+ }
78
+
79
+ export const flattenComments = (
80
+ commentsMap: Record<string, readonly CommentInfo[]>,
81
+ ): CommentInfo[] => {
82
+ const comments: CommentInfo[] = []
83
+ for (const [path, fileComments] of Object.entries(commentsMap)) {
84
+ for (const comment of fileComments) {
85
+ comments.push({ ...comment, path })
86
+ }
87
+ }
88
+ return comments
89
+ }
@@ -0,0 +1,110 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { flattenComments } from '@/utils/review-formatters'
4
+
5
+ export const buildEnhancedPrompt = (
6
+ userPrompt: string,
7
+ systemPrompt: string,
8
+ changeId: string,
9
+ changedFiles: string[],
10
+ ): Effect.Effect<string, ApiError, GerritApiService> =>
11
+ Effect.gen(function* () {
12
+ const gerritApi = yield* GerritApiService
13
+
14
+ const change = yield* gerritApi.getChange(changeId)
15
+ const commentsMap = yield* gerritApi.getComments(changeId)
16
+ const messages = yield* gerritApi.getMessages(changeId)
17
+
18
+ const comments = flattenComments(commentsMap)
19
+
20
+ const promptLines: string[] = []
21
+
22
+ // System prompt FIRST - critical for response format instructions
23
+ promptLines.push(systemPrompt.trim())
24
+ promptLines.push('')
25
+
26
+ // User custom prompt (if provided)
27
+ if (userPrompt.trim()) {
28
+ promptLines.push('ADDITIONAL INSTRUCTIONS FROM USER:')
29
+ promptLines.push('===================================')
30
+ promptLines.push(userPrompt.trim())
31
+ promptLines.push('')
32
+ }
33
+
34
+ // Change metadata section
35
+ promptLines.push('CHANGE INFORMATION')
36
+ promptLines.push('==================')
37
+ promptLines.push(`Change ID: ${change.change_id}`)
38
+ promptLines.push(`Number: ${change._number}`)
39
+ promptLines.push(`Subject: ${change.subject}`)
40
+ promptLines.push(`Project: ${change.project}`)
41
+ promptLines.push(`Branch: ${change.branch}`)
42
+ promptLines.push(`Status: ${change.status}`)
43
+ if (change.owner?.name) {
44
+ promptLines.push(`Author: ${change.owner.name}`)
45
+ }
46
+ promptLines.push('')
47
+
48
+ // Existing comments section
49
+ if (comments.length > 0) {
50
+ promptLines.push('EXISTING COMMENTS')
51
+ promptLines.push('=================')
52
+ for (const comment of comments) {
53
+ const author = comment.author?.name || 'Unknown'
54
+ const date = comment.updated || 'Unknown date'
55
+ const location = comment.path
56
+ ? `${comment.path}${comment.line ? `:${comment.line}` : ''}`
57
+ : 'General'
58
+ promptLines.push(`[${author}] on ${location} (${date}):`)
59
+ promptLines.push(` ${comment.message}`)
60
+ if (comment.unresolved) {
61
+ promptLines.push(' ⚠️ UNRESOLVED')
62
+ }
63
+ promptLines.push('')
64
+ }
65
+ }
66
+
67
+ // Review messages section
68
+ if (messages.length > 0) {
69
+ promptLines.push('REVIEW ACTIVITY')
70
+ promptLines.push('===============')
71
+ for (const message of messages) {
72
+ const author = message.author?.name || 'Unknown'
73
+ const cleanMessage = message.message.trim()
74
+
75
+ // Skip very short automated messages
76
+ if (
77
+ cleanMessage.length >= 10 &&
78
+ !cleanMessage.includes('Build') &&
79
+ !cleanMessage.includes('Patch')
80
+ ) {
81
+ promptLines.push(`[${author}] ${message.date}:`)
82
+ promptLines.push(` ${cleanMessage}`)
83
+ promptLines.push('')
84
+ }
85
+ }
86
+ }
87
+
88
+ // Changed files section
89
+ promptLines.push('CHANGED FILES')
90
+ promptLines.push('=============')
91
+ for (const file of changedFiles) {
92
+ promptLines.push(`- ${file}`)
93
+ }
94
+ promptLines.push('')
95
+
96
+ // Git capabilities section
97
+ promptLines.push('GIT CAPABILITIES')
98
+ promptLines.push('================')
99
+ promptLines.push('You are running in a git repository with full access to:')
100
+ promptLines.push('- git diff, git show, git log for understanding changes')
101
+ promptLines.push('- git blame for code ownership context')
102
+ promptLines.push('- All project files for architectural understanding')
103
+ promptLines.push('- Use these tools to provide comprehensive review')
104
+ promptLines.push('')
105
+
106
+ promptLines.push('Focus your review on the changed files listed above, but feel free to')
107
+ promptLines.push('examine related files, tests, and project structure as needed.')
108
+
109
+ return promptLines.join('\n')
110
+ })