@aaronshaf/ger 1.2.11 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +433 -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 +71 -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 +368 -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 { removeReviewerCommand } from '@/cli/commands/remove-reviewer'
|
|
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('remove-reviewer 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 remove a single reviewer successfully', async () => {
|
|
50
|
+
server.use(
|
|
51
|
+
http.post('*/a/changes/12345/reviewers/reviewer%40example.com/delete', async () => {
|
|
52
|
+
// Gerrit returns 204 No Content on success, which translates to empty response
|
|
53
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
58
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
59
|
+
change: '12345',
|
|
60
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
61
|
+
|
|
62
|
+
await Effect.runPromise(program)
|
|
63
|
+
|
|
64
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
65
|
+
expect(output).toContain('Removed reviewer@example.com')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should remove multiple reviewers successfully', async () => {
|
|
69
|
+
let callCount = 0
|
|
70
|
+
server.use(
|
|
71
|
+
http.post('*/a/changes/12345/reviewers/*/delete', async () => {
|
|
72
|
+
callCount++
|
|
73
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
78
|
+
const program = removeReviewerCommand(['user1@example.com', 'user2@example.com'], {
|
|
79
|
+
change: '12345',
|
|
80
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
81
|
+
|
|
82
|
+
await Effect.runPromise(program)
|
|
83
|
+
|
|
84
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
85
|
+
expect(output).toContain('Removed user1@example.com')
|
|
86
|
+
expect(output).toContain('Removed user2@example.com')
|
|
87
|
+
expect(callCount).toBe(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should pass notify option to API', async () => {
|
|
91
|
+
let receivedNotify: string | undefined
|
|
92
|
+
server.use(
|
|
93
|
+
http.post(
|
|
94
|
+
'*/a/changes/12345/reviewers/reviewer%40example.com/delete',
|
|
95
|
+
async ({ request }) => {
|
|
96
|
+
const body = (await request.json()) as { notify?: string }
|
|
97
|
+
receivedNotify = body.notify
|
|
98
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
104
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
105
|
+
change: '12345',
|
|
106
|
+
notify: 'none',
|
|
107
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
108
|
+
|
|
109
|
+
await Effect.runPromise(program)
|
|
110
|
+
|
|
111
|
+
expect(receivedNotify).toBe('NONE')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should handle not found errors gracefully', async () => {
|
|
115
|
+
server.use(
|
|
116
|
+
http.post('*/a/changes/12345/reviewers/*/delete', async () => {
|
|
117
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
122
|
+
const program = removeReviewerCommand(['nonexistent@example.com'], {
|
|
123
|
+
change: '12345',
|
|
124
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
125
|
+
|
|
126
|
+
await Effect.runPromise(program)
|
|
127
|
+
|
|
128
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
129
|
+
expect(errorOutput).toContain('Failed to remove nonexistent@example.com')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should show error when change ID is not provided', async () => {
|
|
133
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
134
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {}).pipe(
|
|
135
|
+
Effect.provide(GerritApiServiceLive),
|
|
136
|
+
Effect.provide(mockConfigLayer),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const result = await Effect.runPromiseExit(program)
|
|
140
|
+
expect(result._tag).toBe('Failure')
|
|
141
|
+
|
|
142
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
143
|
+
expect(errorOutput).toContain('Change ID is required')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should show error when no reviewers are provided', async () => {
|
|
147
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
148
|
+
const program = removeReviewerCommand([], {
|
|
149
|
+
change: '12345',
|
|
150
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
151
|
+
|
|
152
|
+
const result = await Effect.runPromiseExit(program)
|
|
153
|
+
expect(result._tag).toBe('Failure')
|
|
154
|
+
|
|
155
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
156
|
+
expect(errorOutput).toContain('At least one reviewer is required')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
160
|
+
server.use(
|
|
161
|
+
http.post('*/a/changes/12345/reviewers/reviewer%40example.com/delete', async () => {
|
|
162
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
167
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
168
|
+
change: '12345',
|
|
169
|
+
xml: true,
|
|
170
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
171
|
+
|
|
172
|
+
await Effect.runPromise(program)
|
|
173
|
+
|
|
174
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
175
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
176
|
+
expect(output).toContain('<remove_reviewer_result>')
|
|
177
|
+
expect(output).toContain('<change_id>12345</change_id>')
|
|
178
|
+
expect(output).toContain('<reviewer status="removed">')
|
|
179
|
+
expect(output).toContain('<input>reviewer@example.com</input>')
|
|
180
|
+
expect(output).toContain('<status>success</status>')
|
|
181
|
+
expect(output).toContain('</remove_reviewer_result>')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should output XML format for errors when --xml flag is used', async () => {
|
|
185
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
186
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
187
|
+
xml: true,
|
|
188
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
189
|
+
|
|
190
|
+
const result = await Effect.runPromiseExit(program)
|
|
191
|
+
expect(result._tag).toBe('Failure')
|
|
192
|
+
|
|
193
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
194
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
195
|
+
expect(output).toContain('<remove_reviewer_result>')
|
|
196
|
+
expect(output).toContain('<status>error</status>')
|
|
197
|
+
expect(output).toContain('<error><![CDATA[Change ID is required')
|
|
198
|
+
expect(output).toContain('</remove_reviewer_result>')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should handle network errors gracefully', async () => {
|
|
202
|
+
server.use(
|
|
203
|
+
http.post('*/a/changes/12345/reviewers/*/delete', () => {
|
|
204
|
+
return HttpResponse.text('Internal Server Error', { status: 500 })
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
209
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
210
|
+
change: '12345',
|
|
211
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
212
|
+
|
|
213
|
+
await Effect.runPromise(program)
|
|
214
|
+
|
|
215
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
216
|
+
expect(errorOutput).toContain('Failed to remove reviewer@example.com')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should handle partial success with multiple reviewers', async () => {
|
|
220
|
+
let callCount = 0
|
|
221
|
+
server.use(
|
|
222
|
+
http.post('*/a/changes/12345/reviewers/*/delete', async ({ request }) => {
|
|
223
|
+
callCount++
|
|
224
|
+
const url = new URL(request.url)
|
|
225
|
+
if (url.pathname.includes('invalid')) {
|
|
226
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
227
|
+
}
|
|
228
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
233
|
+
const program = removeReviewerCommand(['valid@example.com', 'invalid@example.com'], {
|
|
234
|
+
change: '12345',
|
|
235
|
+
xml: true,
|
|
236
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
237
|
+
|
|
238
|
+
await Effect.runPromise(program)
|
|
239
|
+
|
|
240
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
241
|
+
expect(output).toContain('<status>partial_failure</status>')
|
|
242
|
+
expect(output).toContain('<reviewer status="removed">')
|
|
243
|
+
expect(output).toContain('<reviewer status="failed">')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should reject invalid notify option', async () => {
|
|
247
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
248
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
249
|
+
change: '12345',
|
|
250
|
+
notify: 'invalid',
|
|
251
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
252
|
+
|
|
253
|
+
const result = await Effect.runPromiseExit(program)
|
|
254
|
+
expect(result._tag).toBe('Failure')
|
|
255
|
+
|
|
256
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
257
|
+
expect(errorOutput).toContain('Invalid notify level: invalid')
|
|
258
|
+
expect(errorOutput).toContain('Valid values: none, owner, owner_reviewers, all')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should support account ID format', async () => {
|
|
262
|
+
server.use(
|
|
263
|
+
http.post('*/a/changes/12345/reviewers/1001/delete', async () => {
|
|
264
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
269
|
+
const program = removeReviewerCommand(['1001'], {
|
|
270
|
+
change: '12345',
|
|
271
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
272
|
+
|
|
273
|
+
await Effect.runPromise(program)
|
|
274
|
+
|
|
275
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
276
|
+
expect(output).toContain('Removed 1001')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should support username format', async () => {
|
|
280
|
+
server.use(
|
|
281
|
+
http.post('*/a/changes/12345/reviewers/johndoe/delete', async () => {
|
|
282
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
287
|
+
const program = removeReviewerCommand(['johndoe'], {
|
|
288
|
+
change: '12345',
|
|
289
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
290
|
+
|
|
291
|
+
await Effect.runPromise(program)
|
|
292
|
+
|
|
293
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
294
|
+
expect(output).toContain('Removed johndoe')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should support Change-ID format', async () => {
|
|
298
|
+
server.use(
|
|
299
|
+
http.post(
|
|
300
|
+
'*/a/changes/If5a3ae8cb5a107e187447802358417f311d0c4b1/reviewers/*/delete',
|
|
301
|
+
async () => {
|
|
302
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
303
|
+
},
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
308
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
309
|
+
change: 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
|
|
310
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
311
|
+
|
|
312
|
+
await Effect.runPromise(program)
|
|
313
|
+
|
|
314
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
315
|
+
expect(output).toContain('Removed reviewer@example.com')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('should handle special characters in reviewer name with proper URL encoding', async () => {
|
|
319
|
+
server.use(
|
|
320
|
+
http.post('*/a/changes/12345/reviewers/user%2Btest%40example.com/delete', async () => {
|
|
321
|
+
return HttpResponse.text(`)]}'\n{}`)
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
326
|
+
const program = removeReviewerCommand(['user+test@example.com'], {
|
|
327
|
+
change: '12345',
|
|
328
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
329
|
+
|
|
330
|
+
await Effect.runPromise(program)
|
|
331
|
+
|
|
332
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
333
|
+
expect(output).toContain('Removed user+test@example.com')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should sanitize CDATA content in XML error output', async () => {
|
|
337
|
+
server.use(
|
|
338
|
+
http.post('*/a/changes/12345/reviewers/*/delete', async () => {
|
|
339
|
+
return HttpResponse.text('Error with ]]> CDATA breaker', { status: 500 })
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
344
|
+
const program = removeReviewerCommand(['reviewer@example.com'], {
|
|
345
|
+
change: '12345',
|
|
346
|
+
xml: true,
|
|
347
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
348
|
+
|
|
349
|
+
await Effect.runPromise(program)
|
|
350
|
+
|
|
351
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
352
|
+
// The ]]> in the error message should be escaped to ]]> to prevent CDATA injection
|
|
353
|
+
expect(output).toContain(']]> CDATA breaker')
|
|
354
|
+
// Ensure the actual CDATA closing sequence is not in the content (only as valid XML tag closing)
|
|
355
|
+
expect(output).not.toContain(']]> CDATA')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
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 { restoreCommand } from '@/cli/commands/restore'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
10
|
+
|
|
11
|
+
const mockChange: ChangeInfo = {
|
|
12
|
+
id: 'test-project~master~I123',
|
|
13
|
+
_number: 12345,
|
|
14
|
+
change_id: 'I123',
|
|
15
|
+
project: 'test-project',
|
|
16
|
+
branch: 'master',
|
|
17
|
+
subject: 'Test change to restore',
|
|
18
|
+
status: 'ABANDONED',
|
|
19
|
+
created: '2024-01-01 10:00:00.000000000',
|
|
20
|
+
updated: '2024-01-01 12:00:00.000000000',
|
|
21
|
+
owner: {
|
|
22
|
+
_account_id: 1000,
|
|
23
|
+
name: 'Test User',
|
|
24
|
+
email: 'test@example.com',
|
|
25
|
+
},
|
|
26
|
+
labels: {
|
|
27
|
+
'Code-Review': {
|
|
28
|
+
value: 0,
|
|
29
|
+
},
|
|
30
|
+
Verified: {
|
|
31
|
+
value: 0,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
work_in_progress: false,
|
|
35
|
+
submittable: false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mockRestoredChange: ChangeInfo = {
|
|
39
|
+
...mockChange,
|
|
40
|
+
status: 'NEW',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create MSW server
|
|
44
|
+
const server = setupServer(
|
|
45
|
+
// Default handler for auth check
|
|
46
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
47
|
+
const auth = request.headers.get('Authorization')
|
|
48
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
49
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
50
|
+
}
|
|
51
|
+
return HttpResponse.json({
|
|
52
|
+
_account_id: 1000,
|
|
53
|
+
name: 'Test User',
|
|
54
|
+
email: 'test@example.com',
|
|
55
|
+
})
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
describe('restore command', () => {
|
|
60
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
61
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
62
|
+
|
|
63
|
+
beforeAll(() => {
|
|
64
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
server.close()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockConsoleLog = mock(() => {})
|
|
73
|
+
mockConsoleError = mock(() => {})
|
|
74
|
+
console.log = mockConsoleLog
|
|
75
|
+
console.error = mockConsoleError
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
server.resetHandlers()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should restore a change with a message', async () => {
|
|
83
|
+
server.use(
|
|
84
|
+
http.post('*/a/changes/12345/restore', async ({ request }) => {
|
|
85
|
+
const body = (await request.json()) as { message?: string }
|
|
86
|
+
expect(body.message).toBe('Restoring this change')
|
|
87
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRestoredChange)}`)
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
92
|
+
const program = restoreCommand('12345', {
|
|
93
|
+
message: 'Restoring this change',
|
|
94
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
95
|
+
|
|
96
|
+
await Effect.runPromise(program)
|
|
97
|
+
|
|
98
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
99
|
+
expect(output).toContain('Restored change 12345')
|
|
100
|
+
expect(output).toContain('Test change to restore')
|
|
101
|
+
expect(output).toContain('Message: Restoring this change')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should restore a change without a message', async () => {
|
|
105
|
+
server.use(
|
|
106
|
+
http.post('*/a/changes/12345/restore', async ({ request }) => {
|
|
107
|
+
const body = (await request.json()) as { message?: string }
|
|
108
|
+
expect(body.message).toBeUndefined()
|
|
109
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRestoredChange)}`)
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
114
|
+
const program = restoreCommand('12345', {}).pipe(
|
|
115
|
+
Effect.provide(GerritApiServiceLive),
|
|
116
|
+
Effect.provide(mockConfigLayer),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await Effect.runPromise(program)
|
|
120
|
+
|
|
121
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
122
|
+
expect(output).toContain('Restored change 12345')
|
|
123
|
+
expect(output).toContain('Test change to restore')
|
|
124
|
+
expect(output).not.toContain('Message:')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
128
|
+
server.use(
|
|
129
|
+
http.post('*/a/changes/12345/restore', async ({ request }) => {
|
|
130
|
+
const body = (await request.json()) as { message?: string }
|
|
131
|
+
expect(body.message).toBe('Restoring for testing')
|
|
132
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRestoredChange)}`)
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
137
|
+
const program = restoreCommand('12345', {
|
|
138
|
+
xml: true,
|
|
139
|
+
message: 'Restoring for testing',
|
|
140
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
141
|
+
|
|
142
|
+
await Effect.runPromise(program)
|
|
143
|
+
|
|
144
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
145
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
146
|
+
expect(output).toContain('<restore_result>')
|
|
147
|
+
expect(output).toContain('<status>success</status>')
|
|
148
|
+
expect(output).toContain('<change_number>12345</change_number>')
|
|
149
|
+
expect(output).toContain('<subject><![CDATA[Test change to restore]]></subject>')
|
|
150
|
+
expect(output).toContain('<message><![CDATA[Restoring for testing]]></message>')
|
|
151
|
+
expect(output).toContain('</restore_result>')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should output XML format without message when no message provided', async () => {
|
|
155
|
+
server.use(
|
|
156
|
+
http.post('*/a/changes/12345/restore', async () => {
|
|
157
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRestoredChange)}`)
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
162
|
+
const program = restoreCommand('12345', { xml: true }).pipe(
|
|
163
|
+
Effect.provide(GerritApiServiceLive),
|
|
164
|
+
Effect.provide(mockConfigLayer),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await Effect.runPromise(program)
|
|
168
|
+
|
|
169
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
170
|
+
expect(output).toContain('<restore_result>')
|
|
171
|
+
expect(output).toContain('<status>success</status>')
|
|
172
|
+
expect(output).not.toContain('<message>')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should handle not found errors gracefully', async () => {
|
|
176
|
+
server.use(
|
|
177
|
+
http.post('*/a/changes/99999/restore', () => {
|
|
178
|
+
return HttpResponse.text('Change not found', { status: 404 })
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
183
|
+
const program = restoreCommand('99999', {
|
|
184
|
+
message: 'Test message',
|
|
185
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
186
|
+
|
|
187
|
+
// Should fail when change is not found
|
|
188
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should show error when change ID is not provided', async () => {
|
|
192
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
193
|
+
const program = restoreCommand(undefined, {}).pipe(
|
|
194
|
+
Effect.provide(GerritApiServiceLive),
|
|
195
|
+
Effect.provide(mockConfigLayer),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
await Effect.runPromise(program)
|
|
199
|
+
|
|
200
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
201
|
+
expect(errorOutput).toContain('Change ID is required')
|
|
202
|
+
expect(errorOutput).toContain('Usage: ger restore <change-id>')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should handle restore API failure', async () => {
|
|
206
|
+
server.use(
|
|
207
|
+
http.post('*/a/changes/12345/restore', () => {
|
|
208
|
+
return HttpResponse.text('Forbidden', { status: 403 })
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
213
|
+
const program = restoreCommand('12345', {
|
|
214
|
+
message: 'Test',
|
|
215
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
|
|
216
|
+
|
|
217
|
+
// Should throw/fail
|
|
218
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('should handle changes that are already active', async () => {
|
|
222
|
+
server.use(
|
|
223
|
+
http.post('*/a/changes/12345/restore', () => {
|
|
224
|
+
return HttpResponse.text('Change is already active', { status: 409 })
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
229
|
+
const program = restoreCommand('12345', {}).pipe(
|
|
230
|
+
Effect.provide(GerritApiServiceLive),
|
|
231
|
+
Effect.provide(mockConfigLayer),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// Should throw/fail
|
|
235
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
236
|
+
})
|
|
237
|
+
})
|