@aaronshaf/ger 1.2.11 → 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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +708 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -196
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- 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
|
+
})
|