@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,145 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
// Mock console
|
|
4
|
+
const mockConsole = {
|
|
5
|
+
log: mock(),
|
|
6
|
+
error: mock(),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Mock global console
|
|
10
|
+
global.console = mockConsole as any
|
|
11
|
+
|
|
12
|
+
describe('Status Command', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockConsole.log.mockReset()
|
|
15
|
+
mockConsole.error.mockReset()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
mock.restore()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('pretty output format', () => {
|
|
23
|
+
test('should show success message when connected', () => {
|
|
24
|
+
console.log('✓ Connected to Gerrit successfully!')
|
|
25
|
+
|
|
26
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✓ Connected to Gerrit successfully!')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('should show failure message when connection fails', () => {
|
|
30
|
+
console.log('✗ Failed to connect to Gerrit')
|
|
31
|
+
console.log('Please check your credentials and network connection')
|
|
32
|
+
|
|
33
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✗ Failed to connect to Gerrit')
|
|
34
|
+
expect(mockConsole.log).toHaveBeenCalledWith(
|
|
35
|
+
'Please check your credentials and network connection',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('XML output format', () => {
|
|
41
|
+
test('should output XML format when connected successfully', () => {
|
|
42
|
+
console.log('<?xml version="1.0" encoding="UTF-8"?>')
|
|
43
|
+
console.log('<status_result>')
|
|
44
|
+
console.log(' <connected>true</connected>')
|
|
45
|
+
console.log('</status_result>')
|
|
46
|
+
|
|
47
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
|
|
48
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<status_result>')
|
|
49
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <connected>true</connected>')
|
|
50
|
+
expect(mockConsole.log).toHaveBeenCalledWith('</status_result>')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should output XML format when connection fails', () => {
|
|
54
|
+
console.log('<?xml version="1.0" encoding="UTF-8"?>')
|
|
55
|
+
console.log('<status_result>')
|
|
56
|
+
console.log(' <connected>false</connected>')
|
|
57
|
+
console.log('</status_result>')
|
|
58
|
+
|
|
59
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<?xml version="1.0" encoding="UTF-8"?>')
|
|
60
|
+
expect(mockConsole.log).toHaveBeenCalledWith('<status_result>')
|
|
61
|
+
expect(mockConsole.log).toHaveBeenCalledWith(' <connected>false</connected>')
|
|
62
|
+
expect(mockConsole.log).toHaveBeenCalledWith('</status_result>')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('option handling', () => {
|
|
67
|
+
test('should handle xml option correctly', () => {
|
|
68
|
+
const options: { xml?: boolean } = { xml: true }
|
|
69
|
+
expect(options.xml).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('should handle undefined xml option correctly', () => {
|
|
73
|
+
const options = { xml: undefined }
|
|
74
|
+
expect(options.xml).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should handle missing xml option', () => {
|
|
78
|
+
const options: { xml?: boolean } = {}
|
|
79
|
+
expect(options.xml).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('connection testing', () => {
|
|
84
|
+
test('should simulate connection success', async () => {
|
|
85
|
+
const mockTestConnection = mock(() => Promise.resolve(true))
|
|
86
|
+
const result = await mockTestConnection()
|
|
87
|
+
expect(result).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('should simulate connection failure', async () => {
|
|
91
|
+
const mockTestConnection = mock(() => Promise.resolve(false))
|
|
92
|
+
const result = await mockTestConnection()
|
|
93
|
+
expect(result).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('should handle connection errors', async () => {
|
|
97
|
+
const mockTestConnection = mock(() => Promise.reject(new Error('Network error')))
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await mockTestConnection()
|
|
101
|
+
} catch (error: any) {
|
|
102
|
+
expect(error.message).toBe('Network error')
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('console output verification', () => {
|
|
108
|
+
test('should output exactly the expected XML structure', () => {
|
|
109
|
+
const expectedXML = [
|
|
110
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
111
|
+
'<status_result>',
|
|
112
|
+
' <connected>true</connected>',
|
|
113
|
+
'</status_result>',
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
expectedXML.forEach((line) => console.log(line))
|
|
117
|
+
|
|
118
|
+
const calls = mockConsole.log.mock.calls.map((call) => call[0])
|
|
119
|
+
expect(calls).toEqual(expectedXML)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('should output exactly one success message in pretty mode', () => {
|
|
123
|
+
console.log('✓ Connected to Gerrit successfully!')
|
|
124
|
+
|
|
125
|
+
expect(mockConsole.log).toHaveBeenCalledTimes(1)
|
|
126
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✓ Connected to Gerrit successfully!')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('status indicators', () => {
|
|
131
|
+
test('should use checkmark for success', () => {
|
|
132
|
+
const successIcon = '✓'
|
|
133
|
+
console.log(`${successIcon} Connected to Gerrit successfully!`)
|
|
134
|
+
|
|
135
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✓ Connected to Gerrit successfully!')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('should use X mark for failure', () => {
|
|
139
|
+
const failureIcon = '✗'
|
|
140
|
+
console.log(`${failureIcon} Failed to connect to Gerrit`)
|
|
141
|
+
|
|
142
|
+
expect(mockConsole.log).toHaveBeenCalledWith('✗ Failed to connect to Gerrit')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,316 @@
|
|
|
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 { submitCommand } from '@/cli/commands/submit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
9
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
10
|
+
|
|
11
|
+
const mockSubmittableChange: 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 ready to submit',
|
|
18
|
+
status: 'NEW',
|
|
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: 2,
|
|
29
|
+
approved: {
|
|
30
|
+
_account_id: 1001,
|
|
31
|
+
name: 'Reviewer',
|
|
32
|
+
email: 'reviewer@example.com',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
Verified: {
|
|
36
|
+
value: 1,
|
|
37
|
+
approved: {
|
|
38
|
+
_account_id: 1002,
|
|
39
|
+
name: 'CI Bot',
|
|
40
|
+
email: 'ci@example.com',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
work_in_progress: false,
|
|
45
|
+
submittable: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mockNotSubmittableChange: ChangeInfo = {
|
|
49
|
+
...mockSubmittableChange,
|
|
50
|
+
submittable: false,
|
|
51
|
+
labels: {
|
|
52
|
+
'Code-Review': {
|
|
53
|
+
value: 0,
|
|
54
|
+
},
|
|
55
|
+
Verified: {
|
|
56
|
+
value: 0,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mockWipChange: ChangeInfo = {
|
|
62
|
+
...mockSubmittableChange,
|
|
63
|
+
submittable: false,
|
|
64
|
+
work_in_progress: true,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mockSubmitResponse = {
|
|
68
|
+
status: 'MERGED' as const,
|
|
69
|
+
change_id: 'I123',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create MSW server
|
|
73
|
+
const server = setupServer(
|
|
74
|
+
// Default handler for auth check
|
|
75
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
76
|
+
const auth = request.headers.get('Authorization')
|
|
77
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
78
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
79
|
+
}
|
|
80
|
+
return HttpResponse.json({
|
|
81
|
+
_account_id: 1000,
|
|
82
|
+
name: 'Test User',
|
|
83
|
+
email: 'test@example.com',
|
|
84
|
+
})
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
describe('submit command', () => {
|
|
89
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
90
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
91
|
+
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
server.close()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
mockConsoleLog = mock(() => {})
|
|
102
|
+
mockConsoleError = mock(() => {})
|
|
103
|
+
console.log = mockConsoleLog
|
|
104
|
+
console.error = mockConsoleError
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
server.resetHandlers()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should submit a submittable change', async () => {
|
|
112
|
+
server.use(
|
|
113
|
+
http.get('*/a/changes/12345', () => {
|
|
114
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmittableChange)}`)
|
|
115
|
+
}),
|
|
116
|
+
http.post('*/a/changes/12345/submit', () => {
|
|
117
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmitResponse)}`)
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
122
|
+
const program = submitCommand('12345', {}).pipe(
|
|
123
|
+
Effect.provide(GerritApiServiceLive),
|
|
124
|
+
Effect.provide(mockConfigLayer),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
await Effect.runPromise(program)
|
|
128
|
+
|
|
129
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
130
|
+
expect(output).toContain('Submitted change 12345')
|
|
131
|
+
expect(output).toContain('Test change ready to submit')
|
|
132
|
+
expect(output).toContain('Status: MERGED')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
136
|
+
server.use(
|
|
137
|
+
http.get('*/a/changes/12345', () => {
|
|
138
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmittableChange)}`)
|
|
139
|
+
}),
|
|
140
|
+
http.post('*/a/changes/12345/submit', () => {
|
|
141
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmitResponse)}`)
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
146
|
+
const program = submitCommand('12345', { xml: true }).pipe(
|
|
147
|
+
Effect.provide(GerritApiServiceLive),
|
|
148
|
+
Effect.provide(mockConfigLayer),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
await Effect.runPromise(program)
|
|
152
|
+
|
|
153
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
154
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
155
|
+
expect(output).toContain('<submit_result>')
|
|
156
|
+
expect(output).toContain('<status>success</status>')
|
|
157
|
+
expect(output).toContain('<change_number>12345</change_number>')
|
|
158
|
+
expect(output).toContain('<subject><![CDATA[Test change ready to submit]]></subject>')
|
|
159
|
+
expect(output).toContain('<submit_status>MERGED</submit_status>')
|
|
160
|
+
expect(output).toContain('</submit_result>')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should reject change that is not submittable', async () => {
|
|
164
|
+
server.use(
|
|
165
|
+
http.get('*/a/changes/12345', () => {
|
|
166
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockNotSubmittableChange)}`)
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
171
|
+
const program = submitCommand('12345', {}).pipe(
|
|
172
|
+
Effect.provide(GerritApiServiceLive),
|
|
173
|
+
Effect.provide(mockConfigLayer),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
await Effect.runPromise(program)
|
|
177
|
+
|
|
178
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
179
|
+
expect(errorOutput).toContain('Change 12345 cannot be submitted')
|
|
180
|
+
expect(errorOutput).toContain('Reasons:')
|
|
181
|
+
expect(errorOutput).toContain('Missing Code-Review+2 approval')
|
|
182
|
+
expect(errorOutput).toContain('Missing Verified+1 approval')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should reject change that is work in progress', async () => {
|
|
186
|
+
server.use(
|
|
187
|
+
http.get('*/a/changes/12345', () => {
|
|
188
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockWipChange)}`)
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
193
|
+
const program = submitCommand('12345', {}).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 12345 cannot be submitted')
|
|
202
|
+
expect(errorOutput).toContain('work-in-progress')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should output XML format for non-submittable change', async () => {
|
|
206
|
+
server.use(
|
|
207
|
+
http.get('*/a/changes/12345', () => {
|
|
208
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockNotSubmittableChange)}`)
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
213
|
+
const program = submitCommand('12345', { xml: true }).pipe(
|
|
214
|
+
Effect.provide(GerritApiServiceLive),
|
|
215
|
+
Effect.provide(mockConfigLayer),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
await Effect.runPromise(program)
|
|
219
|
+
|
|
220
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
221
|
+
expect(output).toContain('<submit_result>')
|
|
222
|
+
expect(output).toContain('<status>error</status>')
|
|
223
|
+
expect(output).toContain('<submittable>false</submittable>')
|
|
224
|
+
expect(output).toContain('<reasons>')
|
|
225
|
+
expect(output).toContain('<reason><![CDATA[Missing Code-Review+2 approval]]></reason>')
|
|
226
|
+
expect(output).toContain('</reasons>')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('should handle not found errors gracefully', async () => {
|
|
230
|
+
server.use(
|
|
231
|
+
http.get('*/a/changes/99999', () => {
|
|
232
|
+
return HttpResponse.text('Change not found', { status: 404 })
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
237
|
+
const program = submitCommand('99999', {}).pipe(
|
|
238
|
+
Effect.provide(GerritApiServiceLive),
|
|
239
|
+
Effect.provide(mockConfigLayer),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// Should fail when change is not found
|
|
243
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should show error when change ID is not provided', async () => {
|
|
247
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
248
|
+
const program = submitCommand(undefined, {}).pipe(
|
|
249
|
+
Effect.provide(GerritApiServiceLive),
|
|
250
|
+
Effect.provide(mockConfigLayer),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await Effect.runPromise(program)
|
|
254
|
+
|
|
255
|
+
const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
|
|
256
|
+
expect(errorOutput).toContain('Change ID is required')
|
|
257
|
+
expect(errorOutput).toContain('Usage: ger submit <change-id>')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('should handle submit API failure', async () => {
|
|
261
|
+
server.use(
|
|
262
|
+
http.get('*/a/changes/12345', () => {
|
|
263
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmittableChange)}`)
|
|
264
|
+
}),
|
|
265
|
+
http.post('*/a/changes/12345/submit', () => {
|
|
266
|
+
return HttpResponse.text('Merge conflict detected', { status: 409 })
|
|
267
|
+
}),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
271
|
+
const program = submitCommand('12345', {}).pipe(
|
|
272
|
+
Effect.provide(GerritApiServiceLive),
|
|
273
|
+
Effect.provide(mockConfigLayer),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Should throw/fail
|
|
277
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should handle network errors', async () => {
|
|
281
|
+
server.use(
|
|
282
|
+
http.get('*/a/changes/12345', () => {
|
|
283
|
+
return HttpResponse.error()
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
288
|
+
const program = submitCommand('12345', {}).pipe(
|
|
289
|
+
Effect.provide(GerritApiServiceLive),
|
|
290
|
+
Effect.provide(mockConfigLayer),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
// Should throw/fail
|
|
294
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should handle API permission errors', async () => {
|
|
298
|
+
server.use(
|
|
299
|
+
http.get('*/a/changes/12345', () => {
|
|
300
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmittableChange)}`)
|
|
301
|
+
}),
|
|
302
|
+
http.post('*/a/changes/12345/submit', () => {
|
|
303
|
+
return HttpResponse.text('Forbidden', { status: 403 })
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
308
|
+
const program = submitCommand('12345', {}).pipe(
|
|
309
|
+
Effect.provide(GerritApiServiceLive),
|
|
310
|
+
Effect.provide(mockConfigLayer),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// Should throw/fail
|
|
314
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test'
|
|
2
|
+
import { buildPushRefspec, validateEmails, PushError } from '@/cli/commands/push'
|
|
3
|
+
|
|
4
|
+
describe('Push Command', () => {
|
|
5
|
+
describe('buildPushRefspec', () => {
|
|
6
|
+
test('should build basic refspec without options', () => {
|
|
7
|
+
const refspec = buildPushRefspec('master', {})
|
|
8
|
+
expect(refspec).toBe('refs/for/master')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('should build refspec with topic', () => {
|
|
12
|
+
const refspec = buildPushRefspec('master', { topic: 'my-feature' })
|
|
13
|
+
expect(refspec).toBe('refs/for/master%topic=my-feature')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('should URL-encode topic with special characters', () => {
|
|
17
|
+
const refspec = buildPushRefspec('master', { topic: 'feature/auth-fix' })
|
|
18
|
+
expect(refspec).toBe('refs/for/master%topic=feature%2Fauth-fix')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('should build refspec with wip flag', () => {
|
|
22
|
+
const refspec = buildPushRefspec('master', { wip: true })
|
|
23
|
+
expect(refspec).toBe('refs/for/master%wip')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('should build refspec with draft flag (alias for wip)', () => {
|
|
27
|
+
const refspec = buildPushRefspec('master', { draft: true })
|
|
28
|
+
expect(refspec).toBe('refs/for/master%wip')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('should build refspec with ready flag', () => {
|
|
32
|
+
const refspec = buildPushRefspec('master', { ready: true })
|
|
33
|
+
expect(refspec).toBe('refs/for/master%ready')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('should build refspec with private flag', () => {
|
|
37
|
+
const refspec = buildPushRefspec('master', { private: true })
|
|
38
|
+
expect(refspec).toBe('refs/for/master%private')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('should build refspec with single reviewer', () => {
|
|
42
|
+
const refspec = buildPushRefspec('master', { reviewer: ['alice@example.com'] })
|
|
43
|
+
expect(refspec).toBe('refs/for/master%r=alice@example.com')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('should build refspec with multiple reviewers', () => {
|
|
47
|
+
const refspec = buildPushRefspec('master', {
|
|
48
|
+
reviewer: ['alice@example.com', 'bob@example.com'],
|
|
49
|
+
})
|
|
50
|
+
expect(refspec).toBe('refs/for/master%r=alice@example.com,r=bob@example.com')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should build refspec with single cc', () => {
|
|
54
|
+
const refspec = buildPushRefspec('master', { cc: ['manager@example.com'] })
|
|
55
|
+
expect(refspec).toBe('refs/for/master%cc=manager@example.com')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should build refspec with multiple ccs', () => {
|
|
59
|
+
const refspec = buildPushRefspec('master', {
|
|
60
|
+
cc: ['manager@example.com', 'lead@example.com'],
|
|
61
|
+
})
|
|
62
|
+
expect(refspec).toBe('refs/for/master%cc=manager@example.com,cc=lead@example.com')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('should build refspec with single hashtag', () => {
|
|
66
|
+
const refspec = buildPushRefspec('master', { hashtag: ['bugfix'] })
|
|
67
|
+
expect(refspec).toBe('refs/for/master%hashtag=bugfix')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('should build refspec with multiple hashtags', () => {
|
|
71
|
+
const refspec = buildPushRefspec('master', { hashtag: ['bugfix', 'urgent'] })
|
|
72
|
+
expect(refspec).toBe('refs/for/master%hashtag=bugfix,hashtag=urgent')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('should URL-encode hashtags with special characters', () => {
|
|
76
|
+
const refspec = buildPushRefspec('master', { hashtag: ['release/v1.0'] })
|
|
77
|
+
expect(refspec).toBe('refs/for/master%hashtag=release%2Fv1.0')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('should build refspec with multiple options combined', () => {
|
|
81
|
+
const refspec = buildPushRefspec('main', {
|
|
82
|
+
topic: 'auth-refactor',
|
|
83
|
+
reviewer: ['alice@example.com'],
|
|
84
|
+
cc: ['manager@example.com'],
|
|
85
|
+
wip: true,
|
|
86
|
+
hashtag: ['security'],
|
|
87
|
+
})
|
|
88
|
+
expect(refspec).toBe(
|
|
89
|
+
'refs/for/main%topic=auth-refactor,wip,r=alice@example.com,cc=manager@example.com,hashtag=security',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('should handle different branch names', () => {
|
|
94
|
+
expect(buildPushRefspec('main', {})).toBe('refs/for/main')
|
|
95
|
+
expect(buildPushRefspec('develop', {})).toBe('refs/for/develop')
|
|
96
|
+
expect(buildPushRefspec('feature/my-branch', {})).toBe('refs/for/feature/my-branch')
|
|
97
|
+
expect(buildPushRefspec('release/v1.0', {})).toBe('refs/for/release/v1.0')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('should preserve order of parameters', () => {
|
|
101
|
+
// The order should be: topic, wip, ready, private, reviewers, ccs, hashtags
|
|
102
|
+
const refspec = buildPushRefspec('master', {
|
|
103
|
+
hashtag: ['tag1'],
|
|
104
|
+
reviewer: ['r@example.com'],
|
|
105
|
+
topic: 'topic1',
|
|
106
|
+
wip: true,
|
|
107
|
+
cc: ['cc@example.com'],
|
|
108
|
+
private: true,
|
|
109
|
+
})
|
|
110
|
+
// Order in the code: topic, wip, ready, private, reviewer, cc, hashtag
|
|
111
|
+
expect(refspec).toBe(
|
|
112
|
+
'refs/for/master%topic=topic1,wip,private,r=r@example.com,cc=cc@example.com,hashtag=tag1',
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('should handle empty arrays gracefully', () => {
|
|
117
|
+
const refspec = buildPushRefspec('master', {
|
|
118
|
+
reviewer: [],
|
|
119
|
+
cc: [],
|
|
120
|
+
hashtag: [],
|
|
121
|
+
})
|
|
122
|
+
expect(refspec).toBe('refs/for/master')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('should not add wip twice when both wip and draft are true', () => {
|
|
126
|
+
const refspec = buildPushRefspec('master', { wip: true, draft: true })
|
|
127
|
+
// Both wip and draft set the wip flag, but we check wip first, so only one 'wip' should appear
|
|
128
|
+
expect(refspec).toBe('refs/for/master%wip')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('validateEmails', () => {
|
|
133
|
+
test('should accept valid email addresses', () => {
|
|
134
|
+
expect(() => validateEmails(['user@example.com'], 'reviewer')).not.toThrow()
|
|
135
|
+
expect(() => validateEmails(['alice@company.org'], 'cc')).not.toThrow()
|
|
136
|
+
expect(() => validateEmails(['test.user@sub.domain.com'], 'reviewer')).not.toThrow()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('should accept multiple valid emails', () => {
|
|
140
|
+
expect(() =>
|
|
141
|
+
validateEmails(['user1@example.com', 'user2@example.com'], 'reviewer'),
|
|
142
|
+
).not.toThrow()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('should accept undefined', () => {
|
|
146
|
+
expect(() => validateEmails(undefined, 'reviewer')).not.toThrow()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('should accept empty array', () => {
|
|
150
|
+
expect(() => validateEmails([], 'reviewer')).not.toThrow()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('should reject email without @', () => {
|
|
154
|
+
expect(() => validateEmails(['userexample.com'], 'reviewer')).toThrow(PushError)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('should reject email without domain', () => {
|
|
158
|
+
expect(() => validateEmails(['user@'], 'reviewer')).toThrow(PushError)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('should reject email without user', () => {
|
|
162
|
+
expect(() => validateEmails(['@example.com'], 'reviewer')).toThrow(PushError)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('should reject email with spaces', () => {
|
|
166
|
+
expect(() => validateEmails(['user @example.com'], 'reviewer')).toThrow(PushError)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('should reject plain username', () => {
|
|
170
|
+
expect(() => validateEmails(['username'], 'reviewer')).toThrow(PushError)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('should include field name in error message', () => {
|
|
174
|
+
try {
|
|
175
|
+
validateEmails(['invalid'], 'reviewer')
|
|
176
|
+
expect(true).toBe(false) // Should not reach here
|
|
177
|
+
} catch (e) {
|
|
178
|
+
expect(e).toBeInstanceOf(PushError)
|
|
179
|
+
expect((e as PushError).message).toContain('reviewer')
|
|
180
|
+
expect((e as PushError).message).toContain('invalid')
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('should fail on first invalid email in array', () => {
|
|
185
|
+
try {
|
|
186
|
+
validateEmails(['valid@example.com', 'invalid', 'another@example.com'], 'cc')
|
|
187
|
+
expect(true).toBe(false)
|
|
188
|
+
} catch (e) {
|
|
189
|
+
expect(e).toBeInstanceOf(PushError)
|
|
190
|
+
expect((e as PushError).message).toContain('invalid')
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|