@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.
- 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 -180
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- package/src/utils.ts +0 -130
|
@@ -0,0 +1,317 @@
|
|
|
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 { voteCommand } from '@/cli/commands/vote'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
|
|
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('vote command', () => {
|
|
27
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
28
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
server.close()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mockConsoleLog = mock(() => {})
|
|
40
|
+
mockConsoleError = mock(() => {})
|
|
41
|
+
console.log = mockConsoleLog
|
|
42
|
+
console.error = mockConsoleError
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
server.resetHandlers()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should vote with Code-Review only', async () => {
|
|
50
|
+
server.use(
|
|
51
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
52
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
53
|
+
expect(body.labels).toEqual({ 'Code-Review': 2 })
|
|
54
|
+
expect(body.message).toBeUndefined()
|
|
55
|
+
return HttpResponse.text(")]}'\n{}")
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
60
|
+
const program = voteCommand('12345', {
|
|
61
|
+
codeReview: 2,
|
|
62
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
63
|
+
|
|
64
|
+
await Effect.runPromise(program)
|
|
65
|
+
|
|
66
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
67
|
+
expect(output).toContain('Voted on change 12345')
|
|
68
|
+
expect(output).toContain('Code-Review: +2')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should vote with Code-Review and Verified', async () => {
|
|
72
|
+
server.use(
|
|
73
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
74
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
75
|
+
expect(body.labels).toEqual({ 'Code-Review': 1, Verified: 1 })
|
|
76
|
+
return HttpResponse.text(")]}'\n{}")
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
81
|
+
const program = voteCommand('12345', {
|
|
82
|
+
codeReview: 1,
|
|
83
|
+
verified: 1,
|
|
84
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
85
|
+
|
|
86
|
+
await Effect.runPromise(program)
|
|
87
|
+
|
|
88
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
89
|
+
expect(output).toContain('Code-Review: +1')
|
|
90
|
+
expect(output).toContain('Verified: +1')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should vote with negative values', async () => {
|
|
94
|
+
server.use(
|
|
95
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
96
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
97
|
+
expect(body.labels).toEqual({ 'Code-Review': -2, Verified: -1 })
|
|
98
|
+
return HttpResponse.text(")]}'\n{}")
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
103
|
+
const program = voteCommand('12345', {
|
|
104
|
+
codeReview: -2,
|
|
105
|
+
verified: -1,
|
|
106
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
107
|
+
|
|
108
|
+
await Effect.runPromise(program)
|
|
109
|
+
|
|
110
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
111
|
+
expect(output).toContain('Code-Review: -2')
|
|
112
|
+
expect(output).toContain('Verified: -1')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should vote with message', async () => {
|
|
116
|
+
server.use(
|
|
117
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
118
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
119
|
+
expect(body.labels).toEqual({ 'Code-Review': 2 })
|
|
120
|
+
expect(body.message).toBe('Looks good to me!')
|
|
121
|
+
return HttpResponse.text(")]}'\n{}")
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
126
|
+
const program = voteCommand('12345', {
|
|
127
|
+
codeReview: 2,
|
|
128
|
+
message: 'Looks good to me!',
|
|
129
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
130
|
+
|
|
131
|
+
await Effect.runPromise(program)
|
|
132
|
+
|
|
133
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
134
|
+
expect(output).toContain('Code-Review: +2')
|
|
135
|
+
expect(output).toContain('Message: Looks good to me!')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should vote with custom labels', async () => {
|
|
139
|
+
server.use(
|
|
140
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
141
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
142
|
+
expect(body.labels).toEqual({ 'Code-Review': 2, 'Custom-Label': 1 })
|
|
143
|
+
return HttpResponse.text(")]}'\n{}")
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
148
|
+
const program = voteCommand('12345', {
|
|
149
|
+
codeReview: 2,
|
|
150
|
+
label: ['Custom-Label', '1'],
|
|
151
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
152
|
+
|
|
153
|
+
await Effect.runPromise(program)
|
|
154
|
+
|
|
155
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
156
|
+
expect(output).toContain('Code-Review: +2')
|
|
157
|
+
expect(output).toContain('Custom-Label: +1')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('should vote with multiple custom labels', async () => {
|
|
161
|
+
server.use(
|
|
162
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
163
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
164
|
+
expect(body.labels).toEqual({ 'Label-A': 1, 'Label-B': -1 })
|
|
165
|
+
return HttpResponse.text(")]}'\n{}")
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
170
|
+
const program = voteCommand('12345', {
|
|
171
|
+
label: ['Label-A', '1', 'Label-B', '-1'],
|
|
172
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
173
|
+
|
|
174
|
+
await Effect.runPromise(program)
|
|
175
|
+
|
|
176
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
177
|
+
expect(output).toContain('Label-A: +1')
|
|
178
|
+
expect(output).toContain('Label-B: -1')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
182
|
+
server.use(
|
|
183
|
+
http.post('*/a/changes/12345/revisions/current/review', async ({ request }) => {
|
|
184
|
+
const body = (await request.json()) as { labels?: Record<string, number>; message?: string }
|
|
185
|
+
expect(body.labels).toEqual({ 'Code-Review': 2, Verified: 1 })
|
|
186
|
+
expect(body.message).toBe('LGTM')
|
|
187
|
+
return HttpResponse.text(")]}'\n{}")
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
192
|
+
const program = voteCommand('12345', {
|
|
193
|
+
xml: true,
|
|
194
|
+
codeReview: 2,
|
|
195
|
+
verified: 1,
|
|
196
|
+
message: 'LGTM',
|
|
197
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
198
|
+
|
|
199
|
+
await Effect.runPromise(program)
|
|
200
|
+
|
|
201
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
202
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
203
|
+
expect(output).toContain('<vote_result>')
|
|
204
|
+
expect(output).toContain('<status>success</status>')
|
|
205
|
+
expect(output).toContain('<change_id>12345</change_id>')
|
|
206
|
+
expect(output).toContain('<label name="Code-Review">2</label>')
|
|
207
|
+
expect(output).toContain('<label name="Verified">1</label>')
|
|
208
|
+
expect(output).toContain('<message><![CDATA[LGTM]]></message>')
|
|
209
|
+
expect(output).toContain('</vote_result>')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should output XML format without message when no message provided', async () => {
|
|
213
|
+
server.use(
|
|
214
|
+
http.post('*/a/changes/12345/revisions/current/review', async () => {
|
|
215
|
+
return HttpResponse.text(")]}'\n{}")
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
220
|
+
const program = voteCommand('12345', {
|
|
221
|
+
xml: true,
|
|
222
|
+
codeReview: 1,
|
|
223
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
224
|
+
|
|
225
|
+
await Effect.runPromise(program)
|
|
226
|
+
|
|
227
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
228
|
+
expect(output).toContain('<vote_result>')
|
|
229
|
+
expect(output).toContain('<status>success</status>')
|
|
230
|
+
expect(output).not.toContain('<message>')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should show error when change ID is not provided', async () => {
|
|
234
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
235
|
+
const program = voteCommand(undefined, { codeReview: 2 }).pipe(
|
|
236
|
+
Effect.provide(GerritApiServiceLive),
|
|
237
|
+
Effect.provide(mockConfigLayer),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
await Effect.runPromise(program)
|
|
241
|
+
|
|
242
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
243
|
+
expect(errorOutput).toContain('Change ID is required')
|
|
244
|
+
expect(errorOutput).toContain('Usage: ger vote <change-id>')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should show error when no labels are provided', async () => {
|
|
248
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
249
|
+
const program = voteCommand('12345', {}).pipe(
|
|
250
|
+
Effect.provide(GerritApiServiceLive),
|
|
251
|
+
Effect.provide(mockConfigLayer),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
await Effect.runPromise(program)
|
|
255
|
+
|
|
256
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
257
|
+
expect(errorOutput).toContain('At least one label is required')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('should handle vote API failure', async () => {
|
|
261
|
+
server.use(
|
|
262
|
+
http.post('*/a/changes/12345/revisions/current/review', () => {
|
|
263
|
+
return HttpResponse.text('Forbidden', { status: 403 })
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
268
|
+
const program = voteCommand('12345', {
|
|
269
|
+
codeReview: 2,
|
|
270
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
271
|
+
|
|
272
|
+
// Should throw/fail
|
|
273
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('should handle not found errors gracefully', async () => {
|
|
277
|
+
server.use(
|
|
278
|
+
http.post('*/a/changes/99999/revisions/current/review', () => {
|
|
279
|
+
return HttpResponse.text('Change not found', { status: 404 })
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
284
|
+
const program = voteCommand('99999', {
|
|
285
|
+
codeReview: 2,
|
|
286
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
287
|
+
|
|
288
|
+
// Should fail when change is not found
|
|
289
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('should reject invalid custom label value', async () => {
|
|
293
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
294
|
+
const program = voteCommand('12345', {
|
|
295
|
+
label: ['Custom-Label', 'not-a-number'],
|
|
296
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
297
|
+
|
|
298
|
+
await Effect.runPromise(program)
|
|
299
|
+
|
|
300
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
301
|
+
expect(errorOutput).toContain('Invalid label value')
|
|
302
|
+
expect(errorOutput).toContain('Label values must be integers')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('should reject odd number of label arguments', async () => {
|
|
306
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
307
|
+
const program = voteCommand('12345', {
|
|
308
|
+
label: ['Custom-Label', '1', 'Another-Label'],
|
|
309
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
310
|
+
|
|
311
|
+
await Effect.runPromise(program)
|
|
312
|
+
|
|
313
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
314
|
+
expect(errorOutput).toContain('Invalid label format')
|
|
315
|
+
expect(errorOutput).toContain('name-value pairs')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
// Mock external dependencies
|
|
4
|
+
const mockFs = {
|
|
5
|
+
existsSync: mock(() => false),
|
|
6
|
+
mkdirSync: mock(),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const mockExecSync = mock()
|
|
10
|
+
const mockSpawnSync = mock()
|
|
11
|
+
|
|
12
|
+
const mockConsole = {
|
|
13
|
+
log: mock(),
|
|
14
|
+
error: mock(),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mock modules
|
|
18
|
+
mock.module('node:fs', () => mockFs)
|
|
19
|
+
mock.module('node:child_process', () => ({
|
|
20
|
+
execSync: mockExecSync,
|
|
21
|
+
spawnSync: mockSpawnSync,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
// Mock global console
|
|
25
|
+
global.console = mockConsole as any
|
|
26
|
+
|
|
27
|
+
describe('Workspace Command', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
// Reset all mocks
|
|
30
|
+
Object.values(mockFs).forEach((mock) => mock.mockReset())
|
|
31
|
+
mockExecSync.mockReset()
|
|
32
|
+
mockSpawnSync.mockReset()
|
|
33
|
+
mockConsole.log.mockReset()
|
|
34
|
+
mockConsole.error.mockReset()
|
|
35
|
+
|
|
36
|
+
// Set default mock behaviors
|
|
37
|
+
mockFs.existsSync.mockReturnValue(false)
|
|
38
|
+
mockExecSync.mockImplementation((command: string) => {
|
|
39
|
+
if (command === 'git rev-parse --git-dir') {
|
|
40
|
+
return '.git'
|
|
41
|
+
}
|
|
42
|
+
if (command === 'git rev-parse --show-toplevel') {
|
|
43
|
+
return '/repo/root'
|
|
44
|
+
}
|
|
45
|
+
if (command === 'git remote -v') {
|
|
46
|
+
return 'origin\thttps://gerrit.example.com/project\t(fetch)\n'
|
|
47
|
+
}
|
|
48
|
+
return ''
|
|
49
|
+
})
|
|
50
|
+
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
mock.restore()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('git repository validation', () => {
|
|
58
|
+
test('should detect git repository', () => {
|
|
59
|
+
mockExecSync.mockReturnValue('.git')
|
|
60
|
+
|
|
61
|
+
const result = mockExecSync('git rev-parse --git-dir')
|
|
62
|
+
expect(result).toBe('.git')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('should handle non-git directory', () => {
|
|
66
|
+
mockExecSync.mockImplementation(() => {
|
|
67
|
+
throw new Error('Not a git repository')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(() => {
|
|
71
|
+
mockExecSync('git rev-parse --git-dir')
|
|
72
|
+
}).toThrow('Not a git repository')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('should find repository root', () => {
|
|
76
|
+
mockExecSync.mockReturnValue('/custom/repo/path')
|
|
77
|
+
|
|
78
|
+
const result = mockExecSync('git rev-parse --show-toplevel')
|
|
79
|
+
expect(result).toBe('/custom/repo/path')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('change specification parsing', () => {
|
|
84
|
+
test('should parse numeric change ID', () => {
|
|
85
|
+
const changeSpec = '12345'
|
|
86
|
+
const parts = changeSpec.split(':')
|
|
87
|
+
|
|
88
|
+
expect(parts[0]).toBe('12345')
|
|
89
|
+
expect(parts[1]).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('should parse change ID with patchset', () => {
|
|
93
|
+
const changeSpec = '12345:3'
|
|
94
|
+
const parts = changeSpec.split(':')
|
|
95
|
+
|
|
96
|
+
expect(parts[0]).toBe('12345')
|
|
97
|
+
expect(parts[1]).toBe('3')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('should handle Change-Id format', () => {
|
|
101
|
+
const changeId = 'I1234567890abcdef1234567890abcdef12345678'
|
|
102
|
+
expect(changeId.startsWith('I')).toBe(true)
|
|
103
|
+
expect(changeId.length).toBe(41)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('remote matching', () => {
|
|
108
|
+
test('should parse git remotes output', () => {
|
|
109
|
+
const remoteOutput = 'origin\thttps://gerrit.example.com/project\t(fetch)\n'
|
|
110
|
+
const lines = remoteOutput.split('\n')
|
|
111
|
+
const match = lines[0].match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
|
|
112
|
+
|
|
113
|
+
expect(match).toBeDefined()
|
|
114
|
+
expect(match?.[1]).toBe('origin')
|
|
115
|
+
expect(match?.[2]).toBe('https://gerrit.example.com/project')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should match HTTP URLs', () => {
|
|
119
|
+
const gerritHost = 'https://gerrit.example.com'
|
|
120
|
+
const remoteUrl = 'https://gerrit.example.com/project'
|
|
121
|
+
|
|
122
|
+
const gerritHostname = new URL(gerritHost).hostname
|
|
123
|
+
const remoteHostname = new URL(remoteUrl).hostname
|
|
124
|
+
|
|
125
|
+
expect(gerritHostname).toBe(remoteHostname)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('should match SSH URLs', () => {
|
|
129
|
+
const gerritHost = 'https://gerrit.example.com'
|
|
130
|
+
const sshUrl = 'git@gerrit.example.com:project'
|
|
131
|
+
|
|
132
|
+
const gerritHostname = new URL(gerritHost).hostname
|
|
133
|
+
const sshHostname = sshUrl.split('@')[1].split(':')[0]
|
|
134
|
+
|
|
135
|
+
expect(gerritHostname).toBe(sshHostname)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('workspace directory management', () => {
|
|
140
|
+
test('should check if directory exists', () => {
|
|
141
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
142
|
+
|
|
143
|
+
const exists = mockFs.existsSync()
|
|
144
|
+
expect(exists).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('should create directory recursively', () => {
|
|
148
|
+
mockFs.mkdirSync('/repo/root/.ger', { recursive: true })
|
|
149
|
+
|
|
150
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/repo/root/.ger', { recursive: true })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('should validate change numbers', () => {
|
|
154
|
+
const validChangeNumber = '12345'
|
|
155
|
+
const invalidChangeNumber = '../../../etc/passwd'
|
|
156
|
+
|
|
157
|
+
expect(/^\d+$/.test(validChangeNumber)).toBe(true)
|
|
158
|
+
expect(/^\d+$/.test(invalidChangeNumber)).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('git operations', () => {
|
|
163
|
+
test('should execute git fetch', () => {
|
|
164
|
+
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
|
|
165
|
+
|
|
166
|
+
const result = mockSpawnSync('git', ['fetch', 'origin', 'refs/changes/45/12345/1'], {
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
cwd: '/repo/root',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(result.status).toBe(0)
|
|
172
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
173
|
+
'git',
|
|
174
|
+
['fetch', 'origin', 'refs/changes/45/12345/1'],
|
|
175
|
+
{ encoding: 'utf8', cwd: '/repo/root' },
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('should handle git fetch failure', () => {
|
|
180
|
+
mockSpawnSync.mockReturnValue({ status: 1, stderr: 'fetch failed' })
|
|
181
|
+
|
|
182
|
+
const result = mockSpawnSync('git', ['fetch', 'origin', 'refs/changes/45/12345/1'])
|
|
183
|
+
|
|
184
|
+
expect(result.status).toBe(1)
|
|
185
|
+
expect(result.stderr).toBe('fetch failed')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('should create git worktree', () => {
|
|
189
|
+
mockSpawnSync.mockReturnValue({ status: 0, stderr: '' })
|
|
190
|
+
|
|
191
|
+
const result = mockSpawnSync('git', ['worktree', 'add', '/workspace/path', 'FETCH_HEAD'])
|
|
192
|
+
|
|
193
|
+
expect(result.status).toBe(0)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('should handle worktree creation failure', () => {
|
|
197
|
+
mockSpawnSync.mockReturnValue({ status: 1, stderr: 'worktree add failed' })
|
|
198
|
+
|
|
199
|
+
const result = mockSpawnSync('git', ['worktree', 'add', '/workspace/path', 'FETCH_HEAD'])
|
|
200
|
+
|
|
201
|
+
expect(result.status).toBe(1)
|
|
202
|
+
expect(result.stderr).toBe('worktree add failed')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('output formats', () => {
|
|
207
|
+
test('should output pretty format messages', () => {
|
|
208
|
+
console.log('Fetching change 12345: Test change subject')
|
|
209
|
+
console.log('Creating worktree at: /repo/root/.ger/12345')
|
|
210
|
+
console.log('✓ Workspace created successfully!')
|
|
211
|
+
console.log(' Run: cd /repo/root/.ger/12345')
|
|
212
|
+
|
|
213
|
+
expect(mockConsole.log).toHaveBeenCalledWith('Fetching change 12345: Test change subject')
|
|
214
|
+
expect(mockConsole.log).toHaveBeenCalledWith('Creating worktree at: /repo/root/.ger/12345')
|
|
215
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✓ Workspace created successfully!')
|
|
216
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' Run: cd /repo/root/.ger/12345')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('should output XML format', () => {
|
|
220
|
+
console.log('<?xml version="1.0" encoding="UTF-8"?>')
|
|
221
|
+
console.log('<workspace>')
|
|
222
|
+
console.log(' <path>/repo/root/.ger/12345</path>')
|
|
223
|
+
console.log(' <change_number>12345</change_number>')
|
|
224
|
+
console.log(' <subject><![CDATA[Test change subject]]></subject>')
|
|
225
|
+
console.log(' <created>true</created>')
|
|
226
|
+
console.log('</workspace>')
|
|
227
|
+
|
|
228
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
|
|
229
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<workspace>')
|
|
230
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <path>/repo/root/.ger/12345</path>')
|
|
231
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <change_number>12345</change_number>')
|
|
232
|
+
expect(mockConsole.log).toHaveBeenCalledWith(
|
|
233
|
+
' <subject><![CDATA[Test change subject]]></subject>',
|
|
234
|
+
)
|
|
235
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <created>true</created>')
|
|
236
|
+
expect(mockConsole.log).toHaveBeenCalledWith('</workspace>')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('should output XML format for existing workspace', () => {
|
|
240
|
+
console.log('<?xml version="1.0" encoding="UTF-8"?>')
|
|
241
|
+
console.log('<workspace>')
|
|
242
|
+
console.log(' <path>/repo/root/.ger/12345</path>')
|
|
243
|
+
console.log(' <exists>true</exists>')
|
|
244
|
+
console.log('</workspace>')
|
|
245
|
+
|
|
246
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
|
|
247
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<workspace>')
|
|
248
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <path>/repo/root/.ger/12345</path>')
|
|
249
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <exists>true</exists>')
|
|
250
|
+
expect(mockConsole.log).toHaveBeenCalledWith('</workspace>')
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('path safety', () => {
|
|
255
|
+
test('should prevent path traversal in workspace names', () => {
|
|
256
|
+
const maliciousPath = '../../../etc/passwd'
|
|
257
|
+
const safePath = '12345'
|
|
258
|
+
|
|
259
|
+
expect(/^\d+$/.test(maliciousPath)).toBe(false)
|
|
260
|
+
expect(/^\d+$/.test(safePath)).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('should use safe path joining', () => {
|
|
264
|
+
const repoRoot = '/repo/root'
|
|
265
|
+
const changeNumber = '12345'
|
|
266
|
+
const workspacePath = `${repoRoot}/.ger/${changeNumber}`
|
|
267
|
+
|
|
268
|
+
expect(workspacePath).toBe('/repo/root/.ger/12345')
|
|
269
|
+
expect(workspacePath).not.toContain('..')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('error handling', () => {
|
|
274
|
+
test('should handle command execution errors', () => {
|
|
275
|
+
mockExecSync.mockImplementation(() => {
|
|
276
|
+
throw new Error('Command failed')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
expect(() => {
|
|
280
|
+
mockExecSync('git status')
|
|
281
|
+
}).toThrow('Command failed')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('should handle spawn errors', () => {
|
|
285
|
+
mockSpawnSync.mockReturnValue({
|
|
286
|
+
status: 1,
|
|
287
|
+
stderr: 'Command not found',
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const result = mockSpawnSync('nonexistent-command')
|
|
291
|
+
expect(result.status).toBe(1)
|
|
292
|
+
expect(result.stderr).toBe('Command not found')
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"
|
|
4
|
-
"module": "
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
5
9
|
"noEmit": true,
|
|
10
|
+
"composite": false,
|
|
6
11
|
"strict": true,
|
|
12
|
+
"downlevelIteration": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"jsx": "react",
|
|
15
|
+
"allowSyntheticDefaultImports": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"allowJs": false,
|
|
18
|
+
"types": [
|
|
19
|
+
"bun-types"
|
|
20
|
+
],
|
|
21
|
+
"declaration": true,
|
|
22
|
+
"isolatedDeclarations": true,
|
|
23
|
+
"noImplicitAny": true,
|
|
24
|
+
"strictNullChecks": true,
|
|
25
|
+
"strictFunctionTypes": true,
|
|
26
|
+
"strictBindCallApply": true,
|
|
27
|
+
"strictPropertyInitialization": true,
|
|
28
|
+
"noImplicitThis": true,
|
|
29
|
+
"alwaysStrict": true,
|
|
30
|
+
"esModuleInterop": true,
|
|
31
|
+
"resolveJsonModule": true,
|
|
32
|
+
"outDir": "./dist",
|
|
33
|
+
"rootDir": "./",
|
|
34
|
+
"baseUrl": ".",
|
|
35
|
+
"paths": {
|
|
36
|
+
"@/*": ["src/*"]
|
|
37
|
+
}
|
|
7
38
|
},
|
|
8
|
-
"include": ["src
|
|
9
|
-
"exclude": ["node_modules"]
|
|
10
|
-
}
|
|
39
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "tests/**/*.ts", "scripts/**/*.ts"],
|
|
40
|
+
"exclude": ["node_modules", "dist", "tmp", "**/*.js", "**/*.jsx"]
|
|
41
|
+
}
|