@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,357 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import { incomingCommand } from '@/cli/commands/incoming'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
|
|
9
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
10
|
+
// Create MSW server
|
|
11
|
+
const server = setupServer(
|
|
12
|
+
// Default handler for auth check
|
|
13
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
14
|
+
const auth = request.headers.get('Authorization')
|
|
15
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
16
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
17
|
+
}
|
|
18
|
+
return HttpResponse.json({
|
|
19
|
+
_account_id: 1000,
|
|
20
|
+
name: 'Test User',
|
|
21
|
+
email: 'test@example.com',
|
|
22
|
+
})
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
describe('incoming command', () => {
|
|
27
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
28
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
// Start MSW server before all tests
|
|
32
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
// Clean up after all tests
|
|
37
|
+
server.close()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// Reset handlers to defaults before each test
|
|
42
|
+
server.resetHandlers()
|
|
43
|
+
|
|
44
|
+
mockConsoleLog = mock(() => {})
|
|
45
|
+
mockConsoleError = mock(() => {})
|
|
46
|
+
console.log = mockConsoleLog
|
|
47
|
+
console.error = mockConsoleError
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
// Clean up after each test
|
|
52
|
+
server.resetHandlers()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should fetch and display incoming changes in pretty format', async () => {
|
|
56
|
+
server.use(
|
|
57
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
58
|
+
const url = new URL(request.url)
|
|
59
|
+
const query = url.searchParams.get('q')
|
|
60
|
+
|
|
61
|
+
// Verify the correct query
|
|
62
|
+
if (query === 'is:open -owner:self -is:wip -is:ignored reviewer:self') {
|
|
63
|
+
return HttpResponse.text(`)]}'\n[
|
|
64
|
+
{
|
|
65
|
+
"id": "team-project~main~I123abc",
|
|
66
|
+
"_number": 1001,
|
|
67
|
+
"project": "team-project",
|
|
68
|
+
"branch": "main",
|
|
69
|
+
"subject": "Fix critical bug in authentication",
|
|
70
|
+
"status": "NEW",
|
|
71
|
+
"change_id": "I123abc",
|
|
72
|
+
"owner": {
|
|
73
|
+
"_account_id": 2001,
|
|
74
|
+
"name": "Alice Developer",
|
|
75
|
+
"email": "alice@example.com"
|
|
76
|
+
},
|
|
77
|
+
"updated": "2024-01-15 10:30:00.000000000"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "team-project~feature%2Fnew-api~I456def",
|
|
81
|
+
"_number": 1002,
|
|
82
|
+
"project": "team-project",
|
|
83
|
+
"branch": "feature/new-api",
|
|
84
|
+
"subject": "Add new API endpoint",
|
|
85
|
+
"status": "NEW",
|
|
86
|
+
"change_id": "I456def",
|
|
87
|
+
"owner": {
|
|
88
|
+
"_account_id": 2002,
|
|
89
|
+
"name": "Bob Builder",
|
|
90
|
+
"email": "bob@example.com"
|
|
91
|
+
},
|
|
92
|
+
"updated": "2024-01-15 11:00:00.000000000"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "another-project~main~I789ghi",
|
|
96
|
+
"_number": 1003,
|
|
97
|
+
"project": "another-project",
|
|
98
|
+
"branch": "main",
|
|
99
|
+
"subject": "Update documentation",
|
|
100
|
+
"status": "NEW",
|
|
101
|
+
"change_id": "I789ghi",
|
|
102
|
+
"owner": {
|
|
103
|
+
"_account_id": 2003,
|
|
104
|
+
"name": "Charlie Coder",
|
|
105
|
+
"email": "charlie@example.com"
|
|
106
|
+
},
|
|
107
|
+
"updated": "2024-01-15 09:00:00.000000000"
|
|
108
|
+
}
|
|
109
|
+
]`)
|
|
110
|
+
}
|
|
111
|
+
return HttpResponse.json([])
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
116
|
+
|
|
117
|
+
const program = incomingCommand({}).pipe(
|
|
118
|
+
Effect.provide(GerritApiServiceLive),
|
|
119
|
+
Effect.provide(mockConfigLayer),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await Effect.runPromise(program)
|
|
123
|
+
|
|
124
|
+
// Check that changes were displayed
|
|
125
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
126
|
+
|
|
127
|
+
// Should not have header since we removed it
|
|
128
|
+
expect(output).not.toContain('Incoming changes for review')
|
|
129
|
+
|
|
130
|
+
// Check project grouping
|
|
131
|
+
expect(output).toContain('team-project')
|
|
132
|
+
expect(output).toContain('another-project')
|
|
133
|
+
|
|
134
|
+
// Check change details
|
|
135
|
+
expect(output).toContain('1001')
|
|
136
|
+
expect(output).toContain('Fix critical bug in authentication')
|
|
137
|
+
expect(output).toContain('by Alice Developer')
|
|
138
|
+
expect(output).toContain('1002')
|
|
139
|
+
expect(output).toContain('Add new API endpoint')
|
|
140
|
+
expect(output).toContain('by Bob Builder')
|
|
141
|
+
expect(output).toContain('1003')
|
|
142
|
+
expect(output).toContain('Update documentation')
|
|
143
|
+
expect(output).toContain('by Charlie Coder')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
147
|
+
server.use(
|
|
148
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
149
|
+
const url = new URL(request.url)
|
|
150
|
+
const query = url.searchParams.get('q')
|
|
151
|
+
|
|
152
|
+
if (query === 'is:open -owner:self -is:wip -is:ignored reviewer:self') {
|
|
153
|
+
return HttpResponse.text(`)]}'\n[
|
|
154
|
+
{
|
|
155
|
+
"id": "xml-project~develop~Ixmltest",
|
|
156
|
+
"_number": 2001,
|
|
157
|
+
"project": "xml-project",
|
|
158
|
+
"branch": "develop",
|
|
159
|
+
"subject": "XML test change",
|
|
160
|
+
"status": "NEW",
|
|
161
|
+
"change_id": "Ixmltest",
|
|
162
|
+
"owner": {
|
|
163
|
+
"_account_id": 3001,
|
|
164
|
+
"name": "XML User",
|
|
165
|
+
"email": "xml@example.com"
|
|
166
|
+
},
|
|
167
|
+
"updated": "2024-01-15 14:00:00.000000000"
|
|
168
|
+
}
|
|
169
|
+
]`)
|
|
170
|
+
}
|
|
171
|
+
return HttpResponse.json([])
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
176
|
+
|
|
177
|
+
const program = incomingCommand({ xml: true }).pipe(
|
|
178
|
+
Effect.provide(GerritApiServiceLive),
|
|
179
|
+
Effect.provide(mockConfigLayer),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
await Effect.runPromise(program)
|
|
183
|
+
|
|
184
|
+
// Check XML output structure
|
|
185
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
186
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
187
|
+
expect(output).toContain('<incoming_reviews>')
|
|
188
|
+
expect(output).toContain('<count>1</count>')
|
|
189
|
+
expect(output).toContain('<changes>')
|
|
190
|
+
expect(output).toContain('<change>')
|
|
191
|
+
expect(output).toContain('<number>2001</number>')
|
|
192
|
+
expect(output).toContain('<subject><![CDATA[XML test change]]></subject>')
|
|
193
|
+
// Project is now an attribute of project element
|
|
194
|
+
expect(output).toContain('<project name="xml-project">')
|
|
195
|
+
expect(output).toContain('<status>NEW</status>')
|
|
196
|
+
expect(output).toContain('<owner>XML User</owner>')
|
|
197
|
+
expect(output).toContain('</change>')
|
|
198
|
+
expect(output).toContain('</changes>')
|
|
199
|
+
expect(output).toContain('</incoming_reviews>')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('should handle no incoming changes gracefully', async () => {
|
|
203
|
+
server.use(
|
|
204
|
+
http.get('*/a/changes/', () => {
|
|
205
|
+
return HttpResponse.text(`)]}'\n[]`)
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
210
|
+
|
|
211
|
+
const program = incomingCommand({}).pipe(
|
|
212
|
+
Effect.provide(GerritApiServiceLive),
|
|
213
|
+
Effect.provide(mockConfigLayer),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
await Effect.runPromise(program)
|
|
217
|
+
|
|
218
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
219
|
+
expect(output).toContain('✓ No incoming reviews')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should handle network failures gracefully', async () => {
|
|
223
|
+
server.use(
|
|
224
|
+
http.get('*/a/changes/', () => {
|
|
225
|
+
return HttpResponse.error()
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
230
|
+
|
|
231
|
+
const program = incomingCommand({}).pipe(
|
|
232
|
+
Effect.provide(GerritApiServiceLive),
|
|
233
|
+
Effect.provide(mockConfigLayer),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should handle authentication failures', async () => {
|
|
240
|
+
server.use(
|
|
241
|
+
http.get('*/a/changes/', () => {
|
|
242
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
247
|
+
|
|
248
|
+
const program = incomingCommand({}).pipe(
|
|
249
|
+
Effect.provide(GerritApiServiceLive),
|
|
250
|
+
Effect.provide(mockConfigLayer),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should properly escape XML special characters', async () => {
|
|
257
|
+
server.use(
|
|
258
|
+
http.get('*/a/changes/', () => {
|
|
259
|
+
return HttpResponse.text(`)]}'\n[
|
|
260
|
+
{
|
|
261
|
+
"id": "test-project~main~Itest",
|
|
262
|
+
"_number": 3001,
|
|
263
|
+
"project": "test<>&\\"project",
|
|
264
|
+
"branch": "main",
|
|
265
|
+
"subject": "Fix <script>alert('XSS')</script> & entities",
|
|
266
|
+
"status": "NEW",
|
|
267
|
+
"change_id": "I<>&test",
|
|
268
|
+
"owner": {
|
|
269
|
+
"_account_id": 4001,
|
|
270
|
+
"name": "User <>&\\"'",
|
|
271
|
+
"email": "test@example.com"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
]`)
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
279
|
+
|
|
280
|
+
const program = incomingCommand({ xml: true }).pipe(
|
|
281
|
+
Effect.provide(GerritApiServiceLive),
|
|
282
|
+
Effect.provide(mockConfigLayer),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
await Effect.runPromise(program)
|
|
286
|
+
|
|
287
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
288
|
+
// Subject should be in CDATA
|
|
289
|
+
expect(output).toContain(
|
|
290
|
+
"<subject><![CDATA[Fix <script>alert('XSS')</script> & entities]]></subject>",
|
|
291
|
+
)
|
|
292
|
+
// Owner name should be preserved in output
|
|
293
|
+
expect(output).toContain('<owner>User <>&"\'</owner>')
|
|
294
|
+
// Project and change_id should be in output
|
|
295
|
+
// Project name should be in the project element attribute
|
|
296
|
+
expect(output).toContain('<project name="test<>&')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should group changes by project alphabetically', async () => {
|
|
300
|
+
server.use(
|
|
301
|
+
http.get('*/a/changes/', () => {
|
|
302
|
+
return HttpResponse.text(`)]}'\n[
|
|
303
|
+
{
|
|
304
|
+
"id": "zebra-project~main~Izebra",
|
|
305
|
+
"_number": 4001,
|
|
306
|
+
"project": "zebra-project",
|
|
307
|
+
"branch": "main",
|
|
308
|
+
"subject": "Change in zebra",
|
|
309
|
+
"status": "NEW",
|
|
310
|
+
"change_id": "Izebra",
|
|
311
|
+
"owner": {"_account_id": 5001, "name": "Zoe"}
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"id": "alpha-project~main~Ialpha",
|
|
315
|
+
"_number": 4002,
|
|
316
|
+
"project": "alpha-project",
|
|
317
|
+
"branch": "main",
|
|
318
|
+
"subject": "Change in alpha",
|
|
319
|
+
"status": "NEW",
|
|
320
|
+
"change_id": "Ialpha",
|
|
321
|
+
"owner": {"_account_id": 5002, "name": "Amy"}
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"id": "beta-project~main~Ibeta",
|
|
325
|
+
"_number": 4003,
|
|
326
|
+
"project": "beta-project",
|
|
327
|
+
"branch": "main",
|
|
328
|
+
"subject": "Change in beta",
|
|
329
|
+
"status": "NEW",
|
|
330
|
+
"change_id": "Ibeta",
|
|
331
|
+
"owner": {"_account_id": 5003, "name": "Ben"}
|
|
332
|
+
}
|
|
333
|
+
]`)
|
|
334
|
+
}),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
338
|
+
|
|
339
|
+
const program = incomingCommand({}).pipe(
|
|
340
|
+
Effect.provide(GerritApiServiceLive),
|
|
341
|
+
Effect.provide(mockConfigLayer),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
await Effect.runPromise(program)
|
|
345
|
+
|
|
346
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
347
|
+
|
|
348
|
+
// Find positions of project names in output
|
|
349
|
+
const alphaPos = output.indexOf('alpha-project')
|
|
350
|
+
const betaPos = output.indexOf('beta-project')
|
|
351
|
+
const zebraPos = output.indexOf('zebra-project')
|
|
352
|
+
|
|
353
|
+
// Verify alphabetical order
|
|
354
|
+
expect(alphaPos).toBeLessThan(betaPos)
|
|
355
|
+
expect(betaPos).toBeLessThan(zebraPos)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
describe('Init Command', () => {
|
|
4
|
+
describe('token obscuring', () => {
|
|
5
|
+
test('should obscure short tokens', () => {
|
|
6
|
+
const obscureToken = (token: string): string => {
|
|
7
|
+
if (token.length <= 8) return '****'
|
|
8
|
+
return `${token.substring(0, 4)}****${token.substring(token.length - 4)}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
expect(obscureToken('1234')).toBe('****')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('should obscure long tokens', () => {
|
|
15
|
+
const obscureToken = (token: string): string => {
|
|
16
|
+
if (token.length <= 8) return '****'
|
|
17
|
+
return `${token.substring(0, 4)}****${token.substring(token.length - 4)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
expect(obscureToken('verylongpassword123456')).toBe('very****3456')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('URL normalization', () => {
|
|
25
|
+
test('should remove trailing slashes', () => {
|
|
26
|
+
const url = 'https://gerrit.example.com/'
|
|
27
|
+
const normalized = url.replace(/\/$/, '')
|
|
28
|
+
expect(normalized).toBe('https://gerrit.example.com')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('input validation', () => {
|
|
33
|
+
test('should require non-empty host', () => {
|
|
34
|
+
const host = ''
|
|
35
|
+
expect(host).toBeFalsy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should require non-empty username', () => {
|
|
39
|
+
const username = ''
|
|
40
|
+
expect(username).toBeFalsy()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('should require non-empty password', () => {
|
|
44
|
+
const password = ''
|
|
45
|
+
expect(password).toBeFalsy()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('control characters', () => {
|
|
50
|
+
test('should handle Ctrl+C', () => {
|
|
51
|
+
const charCode = '\x03'.charCodeAt(0) // Ctrl+C
|
|
52
|
+
expect(charCode).toBe(3)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('should handle Enter key', () => {
|
|
56
|
+
const charCode = '\r'.charCodeAt(0) // Enter
|
|
57
|
+
expect(charCode).toBe(13)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('should handle backspace', () => {
|
|
61
|
+
const charCode = '\x7f'.charCodeAt(0) // Backspace
|
|
62
|
+
expect(charCode).toBe(127)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('should filter printable characters', () => {
|
|
66
|
+
const charCode = 'a'.charCodeAt(0)
|
|
67
|
+
expect(charCode >= 32 && charCode <= 126).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { ConfigService } from '@/services/config'
|
|
6
|
+
import { CommitHookService, CommitHookServiceLive } from '@/services/commit-hook'
|
|
7
|
+
import { createMockConfigService } from '../helpers/config-mock'
|
|
8
|
+
|
|
9
|
+
// Sample valid commit-msg hook script
|
|
10
|
+
const VALID_HOOK_SCRIPT = `#!/bin/sh
|
|
11
|
+
# From Gerrit Code Review 3.x
|
|
12
|
+
#
|
|
13
|
+
# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
|
|
14
|
+
|
|
15
|
+
# Add a Change-Id line to commit messages that don't have one
|
|
16
|
+
add_change_id() {
|
|
17
|
+
# ... hook implementation
|
|
18
|
+
echo "Change-Id: I$(git hash-object -t blob /dev/null)"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
add_change_id
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
// Create MSW server for hook download tests
|
|
25
|
+
const server = setupServer()
|
|
26
|
+
|
|
27
|
+
describe('CommitHookService Integration Tests', () => {
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
server.close()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
server.resetHandlers()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('installHook', () => {
|
|
41
|
+
test('should successfully download hook from Gerrit server', async () => {
|
|
42
|
+
// Setup handler for successful hook download
|
|
43
|
+
server.use(
|
|
44
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
45
|
+
return HttpResponse.text(VALID_HOOK_SCRIPT, { status: 200 })
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
50
|
+
|
|
51
|
+
// Note: We can't fully test installHook without git repo context,
|
|
52
|
+
// but we can verify the HTTP request is made correctly
|
|
53
|
+
const effect = Effect.gen(function* () {
|
|
54
|
+
const service = yield* CommitHookService
|
|
55
|
+
yield* service.installHook()
|
|
56
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
57
|
+
|
|
58
|
+
// Run with Effect.exit to capture the result without throwing
|
|
59
|
+
const exit = await Effect.runPromiseExit(effect)
|
|
60
|
+
|
|
61
|
+
// The test verifies the service can be constructed and HTTP fetch succeeds
|
|
62
|
+
// It will fail with NotGitRepoError because we're not in a git repo,
|
|
63
|
+
// but it should NOT fail due to HTTP issues
|
|
64
|
+
if (exit._tag === 'Failure') {
|
|
65
|
+
const errorStr = String(exit.cause)
|
|
66
|
+
// Should fail due to git repo issues, not HTTP issues
|
|
67
|
+
expect(errorStr).not.toContain('Failed to download')
|
|
68
|
+
expect(errorStr).not.toContain('fetch')
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('should handle 404 error when hook URL is not found', async () => {
|
|
73
|
+
server.use(
|
|
74
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
75
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
80
|
+
|
|
81
|
+
const effect = Effect.gen(function* () {
|
|
82
|
+
const service = yield* CommitHookService
|
|
83
|
+
yield* service.installHook()
|
|
84
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
85
|
+
|
|
86
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
87
|
+
|
|
88
|
+
// Should fail with HookInstallError due to 404
|
|
89
|
+
expect(result).toBeInstanceOf(Error)
|
|
90
|
+
expect(String(result)).toContain('Failed to download')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('should handle 500 server error gracefully', async () => {
|
|
94
|
+
server.use(
|
|
95
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
96
|
+
return HttpResponse.text('Internal Server Error', { status: 500 })
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
101
|
+
|
|
102
|
+
const effect = Effect.gen(function* () {
|
|
103
|
+
const service = yield* CommitHookService
|
|
104
|
+
yield* service.installHook()
|
|
105
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
106
|
+
|
|
107
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
108
|
+
|
|
109
|
+
expect(result).toBeInstanceOf(Error)
|
|
110
|
+
expect(String(result)).toContain('Failed to download')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('should reject invalid hook content (not a script)', async () => {
|
|
114
|
+
server.use(
|
|
115
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
116
|
+
// Return HTML instead of shell script
|
|
117
|
+
return HttpResponse.text('<html><body>Error page</body></html>', { status: 200 })
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
122
|
+
|
|
123
|
+
const effect = Effect.gen(function* () {
|
|
124
|
+
const service = yield* CommitHookService
|
|
125
|
+
yield* service.installHook()
|
|
126
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
127
|
+
|
|
128
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
129
|
+
|
|
130
|
+
expect(result).toBeInstanceOf(Error)
|
|
131
|
+
expect(String(result)).toContain('valid script')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('should handle network timeout', async () => {
|
|
135
|
+
server.use(
|
|
136
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', async () => {
|
|
137
|
+
// Simulate network delay that would cause timeout
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
139
|
+
return HttpResponse.error()
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
144
|
+
|
|
145
|
+
const effect = Effect.gen(function* () {
|
|
146
|
+
const service = yield* CommitHookService
|
|
147
|
+
yield* service.installHook()
|
|
148
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
149
|
+
|
|
150
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
151
|
+
|
|
152
|
+
expect(result).toBeInstanceOf(Error)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('should handle host with trailing slash', async () => {
|
|
156
|
+
// Use a host with trailing slash
|
|
157
|
+
const configWithTrailingSlash = createMockConfigService({
|
|
158
|
+
host: 'https://test.gerrit.com/',
|
|
159
|
+
username: 'testuser',
|
|
160
|
+
password: 'testpass',
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
server.use(
|
|
164
|
+
// The trailing slash should be normalized, so this handler should match
|
|
165
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
166
|
+
return HttpResponse.text(VALID_HOOK_SCRIPT, { status: 200 })
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const mockConfigLayer = Layer.succeed(ConfigService, configWithTrailingSlash)
|
|
171
|
+
|
|
172
|
+
const effect = Effect.gen(function* () {
|
|
173
|
+
const service = yield* CommitHookService
|
|
174
|
+
yield* service.installHook()
|
|
175
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
176
|
+
|
|
177
|
+
// Should not fail due to double slash in URL
|
|
178
|
+
await Effect.runPromise(effect).catch((e) => {
|
|
179
|
+
// May fail for git repo reasons, but should not fail for URL issues
|
|
180
|
+
expect(String(e)).not.toContain('//tools')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('hook script validation', () => {
|
|
186
|
+
test('should accept sh shebang', async () => {
|
|
187
|
+
server.use(
|
|
188
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
189
|
+
return HttpResponse.text('#!/bin/sh\necho "hook"', { status: 200 })
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
194
|
+
|
|
195
|
+
const effect = Effect.gen(function* () {
|
|
196
|
+
const service = yield* CommitHookService
|
|
197
|
+
yield* service.installHook()
|
|
198
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
199
|
+
|
|
200
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
201
|
+
|
|
202
|
+
// Should not fail with "not a valid script" error
|
|
203
|
+
expect(String(result)).not.toContain('valid script')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('should accept bash shebang', async () => {
|
|
207
|
+
server.use(
|
|
208
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
209
|
+
return HttpResponse.text('#!/bin/bash\necho "hook"', { status: 200 })
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
214
|
+
|
|
215
|
+
const effect = Effect.gen(function* () {
|
|
216
|
+
const service = yield* CommitHookService
|
|
217
|
+
yield* service.installHook()
|
|
218
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
219
|
+
|
|
220
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
221
|
+
|
|
222
|
+
// Should not fail with "not a valid script" error
|
|
223
|
+
expect(String(result)).not.toContain('valid script')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('should accept env shebang', async () => {
|
|
227
|
+
server.use(
|
|
228
|
+
http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
|
|
229
|
+
return HttpResponse.text('#!/usr/bin/env sh\necho "hook"', { status: 200 })
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
234
|
+
|
|
235
|
+
const effect = Effect.gen(function* () {
|
|
236
|
+
const service = yield* CommitHookService
|
|
237
|
+
yield* service.installHook()
|
|
238
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
|
|
239
|
+
|
|
240
|
+
const result = await Effect.runPromise(effect).catch((e) => e)
|
|
241
|
+
|
|
242
|
+
// Should not fail with "not a valid script" error
|
|
243
|
+
expect(String(result)).not.toContain('valid script')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
})
|