@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,55 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { parseChangeInput } from '@/cli/commands/checkout'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for parseChangeInput function
|
|
6
|
+
*
|
|
7
|
+
* Validates parsing of various input formats:
|
|
8
|
+
* - Plain change numbers
|
|
9
|
+
* - Change numbers with patchsets (12345/3)
|
|
10
|
+
* - Change-IDs (Iabc123...)
|
|
11
|
+
* - URLs with/without patchsets
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe('parseChangeInput', () => {
|
|
15
|
+
test('should parse plain change number', () => {
|
|
16
|
+
const result = parseChangeInput('12345')
|
|
17
|
+
expect(result).toEqual({ changeId: '12345' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('should parse change number with patchset', () => {
|
|
21
|
+
const result = parseChangeInput('12345/3')
|
|
22
|
+
expect(result).toEqual({ changeId: '12345', patchset: 3 })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('should parse Change-ID', () => {
|
|
26
|
+
const result = parseChangeInput('Iabc123def456')
|
|
27
|
+
expect(result).toEqual({ changeId: 'Iabc123def456' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('should parse URL with change number', () => {
|
|
31
|
+
const result = parseChangeInput('https://gerrit.example.com/c/project/+/12345')
|
|
32
|
+
expect(result).toEqual({ changeId: '12345' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('should parse URL with change number and patchset', () => {
|
|
36
|
+
const result = parseChangeInput('https://gerrit.example.com/c/project/+/12345/3')
|
|
37
|
+
expect(result).toEqual({ changeId: '12345', patchset: 3 })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('should parse URL with hash format', () => {
|
|
41
|
+
const result = parseChangeInput('https://gerrit.example.com/#/c/project/+/12345')
|
|
42
|
+
expect(result).toEqual({ changeId: '12345' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('should handle whitespace', () => {
|
|
46
|
+
const result = parseChangeInput(' 12345/2 ')
|
|
47
|
+
expect(result).toEqual({ changeId: '12345', patchset: 2 })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('should handle invalid patchset gracefully', () => {
|
|
51
|
+
const result = parseChangeInput('12345/abc')
|
|
52
|
+
expect(result.changeId).toBe('12345')
|
|
53
|
+
expect(result.patchset).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'
|
|
2
|
+
import { HttpResponse, http } from 'msw'
|
|
3
|
+
import { setupServer } from 'msw/node'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { checkoutCommand, InvalidInputError } from '@/cli/commands/checkout'
|
|
6
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from '../helpers/config-mock'
|
|
9
|
+
import type { ChangeInfo, RevisionInfo } from '@/schemas/gerrit'
|
|
10
|
+
import * as childProcess from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Input validation and security tests
|
|
14
|
+
*
|
|
15
|
+
* Tests cover:
|
|
16
|
+
* - Shell injection prevention
|
|
17
|
+
* - Invalid input format rejection
|
|
18
|
+
* - Malicious remote/ref validation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const server = setupServer(
|
|
22
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
23
|
+
const auth = request.headers.get('Authorization')
|
|
24
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
25
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
26
|
+
}
|
|
27
|
+
return HttpResponse.json({
|
|
28
|
+
_account_id: 1000,
|
|
29
|
+
name: 'Test User',
|
|
30
|
+
email: 'test@example.com',
|
|
31
|
+
})
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
describe('Checkout Command - Input Validation', () => {
|
|
36
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
37
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
38
|
+
let mockExecSync: ReturnType<typeof spyOn>
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
42
|
+
mockConsoleLog = mock(() => {})
|
|
43
|
+
mockConsoleError = mock(() => {})
|
|
44
|
+
console.log = mockConsoleLog
|
|
45
|
+
console.error = mockConsoleError
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
server.close()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
server.resetHandlers()
|
|
54
|
+
mockConsoleLog.mockClear()
|
|
55
|
+
mockConsoleError.mockClear()
|
|
56
|
+
mockExecSync?.mockRestore()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('should reject malicious remote name with shell injection', async () => {
|
|
60
|
+
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
|
|
61
|
+
command: string,
|
|
62
|
+
_options?: unknown,
|
|
63
|
+
) => {
|
|
64
|
+
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
|
|
65
|
+
if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
|
|
66
|
+
if (command === 'git remote -v') {
|
|
67
|
+
return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
|
|
68
|
+
}
|
|
69
|
+
return Buffer.from('')
|
|
70
|
+
}) as typeof childProcess.execSync)
|
|
71
|
+
|
|
72
|
+
const mockChange: ChangeInfo = {
|
|
73
|
+
id: 'test-project~main~Iabc123',
|
|
74
|
+
_number: 12345,
|
|
75
|
+
project: 'test-project',
|
|
76
|
+
branch: 'main',
|
|
77
|
+
change_id: 'Iabc123',
|
|
78
|
+
subject: 'Test change',
|
|
79
|
+
status: 'NEW',
|
|
80
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
81
|
+
updated: '2024-01-15 10:00:00.000000000',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mockRevision: RevisionInfo = {
|
|
85
|
+
_number: 1,
|
|
86
|
+
ref: 'refs/changes/45/12345/1',
|
|
87
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
88
|
+
uploader: {
|
|
89
|
+
_account_id: 1000,
|
|
90
|
+
name: 'Test User',
|
|
91
|
+
email: 'test@example.com',
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
server.use(
|
|
96
|
+
http.get('*/a/changes/12345', () => {
|
|
97
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
|
|
98
|
+
}),
|
|
99
|
+
http.get('*/a/changes/12345/revisions/current', () => {
|
|
100
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
105
|
+
|
|
106
|
+
// Try to inject shell command in remote option
|
|
107
|
+
const program = checkoutCommand('12345', { remote: 'origin; rm -rf /' }).pipe(
|
|
108
|
+
Effect.provide(GerritApiServiceLive),
|
|
109
|
+
Effect.provide(mockConfigLayer),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const result = await Effect.runPromise(program.pipe(Effect.either))
|
|
113
|
+
|
|
114
|
+
// Should fail with InvalidInputError
|
|
115
|
+
expect(result._tag).toBe('Left')
|
|
116
|
+
if (result._tag === 'Left') {
|
|
117
|
+
expect(result.left).toBeInstanceOf(InvalidInputError)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('should reject malicious ref with invalid format', async () => {
|
|
122
|
+
mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
|
|
123
|
+
command: string,
|
|
124
|
+
_options?: unknown,
|
|
125
|
+
) => {
|
|
126
|
+
if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
|
|
127
|
+
return Buffer.from('')
|
|
128
|
+
}) as typeof childProcess.execSync)
|
|
129
|
+
|
|
130
|
+
const mockChange: ChangeInfo = {
|
|
131
|
+
id: 'test-project~main~Iabc123',
|
|
132
|
+
_number: 12345,
|
|
133
|
+
project: 'test-project',
|
|
134
|
+
branch: 'main',
|
|
135
|
+
change_id: 'Iabc123',
|
|
136
|
+
subject: 'Test change',
|
|
137
|
+
status: 'NEW',
|
|
138
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
139
|
+
updated: '2024-01-15 10:00:00.000000000',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Malicious ref that doesn't match Gerrit format
|
|
143
|
+
const mockRevision: RevisionInfo = {
|
|
144
|
+
_number: 1,
|
|
145
|
+
ref: '$(malicious command)',
|
|
146
|
+
created: '2024-01-15 10:00:00.000000000',
|
|
147
|
+
uploader: {
|
|
148
|
+
_account_id: 1000,
|
|
149
|
+
name: 'Test User',
|
|
150
|
+
email: 'test@example.com',
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
server.use(
|
|
155
|
+
http.get('*/a/changes/12345', () => {
|
|
156
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
|
|
157
|
+
}),
|
|
158
|
+
http.get('*/a/changes/12345/revisions/current', () => {
|
|
159
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
164
|
+
|
|
165
|
+
const program = checkoutCommand('12345', {}).pipe(
|
|
166
|
+
Effect.provide(GerritApiServiceLive),
|
|
167
|
+
Effect.provide(mockConfigLayer),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const result = await Effect.runPromise(program.pipe(Effect.either))
|
|
171
|
+
|
|
172
|
+
// Should fail with InvalidInputError due to invalid ref format
|
|
173
|
+
expect(result._tag).toBe('Left')
|
|
174
|
+
if (result._tag === 'Left') {
|
|
175
|
+
expect(result.left).toBeInstanceOf(InvalidInputError)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterEach, afterAll } from 'bun:test'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { setupServer } from 'msw/node'
|
|
4
|
+
import { Effect, Layer } from 'effect'
|
|
5
|
+
import { ConfigService } from '@/services/config'
|
|
6
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
+
import { commentCommand } from '@/cli/commands/comment'
|
|
8
|
+
import { EventEmitter } from 'node:events'
|
|
9
|
+
|
|
10
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
11
|
+
// Create a mock process.stdin for testing
|
|
12
|
+
class MockProcessStdin extends EventEmitter {
|
|
13
|
+
isTTY = false
|
|
14
|
+
readable = true
|
|
15
|
+
|
|
16
|
+
emit(event: string, data?: any): boolean {
|
|
17
|
+
if (event === 'data') {
|
|
18
|
+
super.emit('data', Buffer.from(data))
|
|
19
|
+
// Automatically emit 'end' after data
|
|
20
|
+
setTimeout(() => super.emit('end'), 0)
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
return super.emit(event, data)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const server = setupServer()
|
|
28
|
+
|
|
29
|
+
beforeAll(() => server.listen())
|
|
30
|
+
afterEach(() => server.resetHandlers())
|
|
31
|
+
afterAll(() => server.close())
|
|
32
|
+
|
|
33
|
+
describe('comment command - advanced batch features', () => {
|
|
34
|
+
const mockProcessStdin = new MockProcessStdin()
|
|
35
|
+
|
|
36
|
+
test('should handle batch comments with side parameter', async () => {
|
|
37
|
+
const originalStdin = process.stdin
|
|
38
|
+
Object.defineProperty(process, 'stdin', {
|
|
39
|
+
value: mockProcessStdin,
|
|
40
|
+
configurable: true,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
server.use(
|
|
44
|
+
http.get('*/a/changes/:changeId', () => {
|
|
45
|
+
return HttpResponse.text(`)]}'\n{
|
|
46
|
+
"id": "test-project~main~I123abc",
|
|
47
|
+
"_number": 12345,
|
|
48
|
+
"project": "test-project",
|
|
49
|
+
"branch": "main",
|
|
50
|
+
"change_id": "I123abc",
|
|
51
|
+
"subject": "Test change",
|
|
52
|
+
"status": "NEW",
|
|
53
|
+
"created": "2024-01-15 10:00:00.000000000",
|
|
54
|
+
"updated": "2024-01-15 10:00:00.000000000"
|
|
55
|
+
}`)
|
|
56
|
+
}),
|
|
57
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
|
|
58
|
+
const body = (await request.json()) as {
|
|
59
|
+
message?: string
|
|
60
|
+
comments?: Record<string, unknown[]>
|
|
61
|
+
}
|
|
62
|
+
expect(body.comments).toBeDefined()
|
|
63
|
+
|
|
64
|
+
const fileComments = body.comments?.['src/main.js'] as Array<{
|
|
65
|
+
line?: number
|
|
66
|
+
side?: string
|
|
67
|
+
message: string
|
|
68
|
+
}>
|
|
69
|
+
|
|
70
|
+
expect(fileComments?.length).toBe(2)
|
|
71
|
+
expect(fileComments?.[0]).toMatchObject({
|
|
72
|
+
line: 10,
|
|
73
|
+
side: 'PARENT',
|
|
74
|
+
message: 'Why was this removed?',
|
|
75
|
+
})
|
|
76
|
+
expect(fileComments?.[1]).toMatchObject({
|
|
77
|
+
line: 10,
|
|
78
|
+
side: 'REVISION',
|
|
79
|
+
message: 'Good improvement',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return HttpResponse.json({})
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
87
|
+
|
|
88
|
+
const program = commentCommand('12345', { batch: true }).pipe(
|
|
89
|
+
Effect.provide(GerritApiServiceLive),
|
|
90
|
+
Effect.provide(mockConfigLayer),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Simulate stdin data with side parameter
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
mockProcessStdin.emit(
|
|
96
|
+
'data',
|
|
97
|
+
JSON.stringify([
|
|
98
|
+
{ file: 'src/main.js', line: 10, message: 'Why was this removed?', side: 'PARENT' },
|
|
99
|
+
{ file: 'src/main.js', line: 10, message: 'Good improvement', side: 'REVISION' },
|
|
100
|
+
]),
|
|
101
|
+
)
|
|
102
|
+
}, 10)
|
|
103
|
+
|
|
104
|
+
await Effect.runPromise(program)
|
|
105
|
+
|
|
106
|
+
// Restore process.stdin
|
|
107
|
+
Object.defineProperty(process, 'stdin', {
|
|
108
|
+
value: originalStdin,
|
|
109
|
+
configurable: true,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('should handle batch comments with range parameter', async () => {
|
|
114
|
+
const originalStdin = process.stdin
|
|
115
|
+
Object.defineProperty(process, 'stdin', {
|
|
116
|
+
value: mockProcessStdin,
|
|
117
|
+
configurable: true,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
server.use(
|
|
121
|
+
http.get('*/a/changes/:changeId', () => {
|
|
122
|
+
return HttpResponse.text(`)]}'\n{
|
|
123
|
+
"id": "test-project~main~I123abc",
|
|
124
|
+
"_number": 12345,
|
|
125
|
+
"project": "test-project",
|
|
126
|
+
"branch": "main",
|
|
127
|
+
"change_id": "I123abc",
|
|
128
|
+
"subject": "Test change",
|
|
129
|
+
"status": "NEW"
|
|
130
|
+
}`)
|
|
131
|
+
}),
|
|
132
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
|
|
133
|
+
const body = (await request.json()) as {
|
|
134
|
+
comments?: Record<string, unknown[]>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fileComments = body.comments?.['src/Calculator.java'] as Array<{
|
|
138
|
+
range?: {
|
|
139
|
+
start_line: number
|
|
140
|
+
end_line: number
|
|
141
|
+
start_character?: number
|
|
142
|
+
end_character?: number
|
|
143
|
+
}
|
|
144
|
+
message: string
|
|
145
|
+
}>
|
|
146
|
+
|
|
147
|
+
expect(fileComments?.length).toBe(3)
|
|
148
|
+
|
|
149
|
+
// Multi-line range comment
|
|
150
|
+
expect(fileComments?.[0]).toMatchObject({
|
|
151
|
+
range: {
|
|
152
|
+
start_line: 50,
|
|
153
|
+
end_line: 55,
|
|
154
|
+
},
|
|
155
|
+
message: 'This block needs refactoring',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Character-specific range
|
|
159
|
+
expect(fileComments?.[1]).toMatchObject({
|
|
160
|
+
range: {
|
|
161
|
+
start_line: 10,
|
|
162
|
+
start_character: 8,
|
|
163
|
+
end_line: 10,
|
|
164
|
+
end_character: 25,
|
|
165
|
+
},
|
|
166
|
+
message: 'Variable name is confusing',
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// Mixed with regular line comment
|
|
170
|
+
expect(fileComments?.[2]).toMatchObject({
|
|
171
|
+
line: 42,
|
|
172
|
+
message: 'Add null check here',
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return HttpResponse.json({})
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
180
|
+
|
|
181
|
+
const program = commentCommand('12345', { batch: true }).pipe(
|
|
182
|
+
Effect.provide(GerritApiServiceLive),
|
|
183
|
+
Effect.provide(mockConfigLayer),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Simulate stdin data with range parameter
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
mockProcessStdin.emit(
|
|
189
|
+
'data',
|
|
190
|
+
JSON.stringify([
|
|
191
|
+
{
|
|
192
|
+
file: 'src/Calculator.java',
|
|
193
|
+
range: { start_line: 50, end_line: 55 },
|
|
194
|
+
message: 'This block needs refactoring',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
file: 'src/Calculator.java',
|
|
198
|
+
range: { start_line: 10, start_character: 8, end_line: 10, end_character: 25 },
|
|
199
|
+
message: 'Variable name is confusing',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
file: 'src/Calculator.java',
|
|
203
|
+
line: 42,
|
|
204
|
+
message: 'Add null check here',
|
|
205
|
+
},
|
|
206
|
+
]),
|
|
207
|
+
)
|
|
208
|
+
}, 10)
|
|
209
|
+
|
|
210
|
+
await Effect.runPromise(program)
|
|
211
|
+
|
|
212
|
+
// Restore process.stdin
|
|
213
|
+
Object.defineProperty(process, 'stdin', {
|
|
214
|
+
value: originalStdin,
|
|
215
|
+
configurable: true,
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('should handle batch comments with both side and range', async () => {
|
|
220
|
+
const originalStdin = process.stdin
|
|
221
|
+
Object.defineProperty(process, 'stdin', {
|
|
222
|
+
value: mockProcessStdin,
|
|
223
|
+
configurable: true,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
server.use(
|
|
227
|
+
http.get('*/a/changes/:changeId', () => {
|
|
228
|
+
return HttpResponse.text(`)]}'\n{
|
|
229
|
+
"id": "test-project~main~I123abc",
|
|
230
|
+
"_number": 12345,
|
|
231
|
+
"project": "test-project",
|
|
232
|
+
"branch": "main",
|
|
233
|
+
"change_id": "I123abc",
|
|
234
|
+
"subject": "Test change",
|
|
235
|
+
"status": "NEW"
|
|
236
|
+
}`)
|
|
237
|
+
}),
|
|
238
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
|
|
239
|
+
const body = (await request.json()) as {
|
|
240
|
+
comments?: Record<string, unknown[]>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const fileComments = body.comments?.['src/Service.java'] as Array<{
|
|
244
|
+
range?: {
|
|
245
|
+
start_line: number
|
|
246
|
+
end_line: number
|
|
247
|
+
}
|
|
248
|
+
side?: string
|
|
249
|
+
message: string
|
|
250
|
+
unresolved?: boolean
|
|
251
|
+
}>
|
|
252
|
+
|
|
253
|
+
expect(fileComments?.length).toBe(2)
|
|
254
|
+
|
|
255
|
+
// Range comment on PARENT side
|
|
256
|
+
expect(fileComments?.[0]).toMatchObject({
|
|
257
|
+
range: {
|
|
258
|
+
start_line: 20,
|
|
259
|
+
end_line: 35,
|
|
260
|
+
},
|
|
261
|
+
side: 'PARENT',
|
|
262
|
+
message: 'Why was this error handling removed?',
|
|
263
|
+
unresolved: true,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Range comment on REVISION side
|
|
267
|
+
expect(fileComments?.[1]).toMatchObject({
|
|
268
|
+
range: {
|
|
269
|
+
start_line: 20,
|
|
270
|
+
end_line: 35,
|
|
271
|
+
},
|
|
272
|
+
side: 'REVISION',
|
|
273
|
+
message: 'New error handling looks good, but consider extracting',
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
return HttpResponse.json({})
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
281
|
+
|
|
282
|
+
const program = commentCommand('12345', { batch: true }).pipe(
|
|
283
|
+
Effect.provide(GerritApiServiceLive),
|
|
284
|
+
Effect.provide(mockConfigLayer),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
// Simulate stdin data with both range and side
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
mockProcessStdin.emit(
|
|
290
|
+
'data',
|
|
291
|
+
JSON.stringify([
|
|
292
|
+
{
|
|
293
|
+
file: 'src/Service.java',
|
|
294
|
+
range: { start_line: 20, end_line: 35 },
|
|
295
|
+
side: 'PARENT',
|
|
296
|
+
message: 'Why was this error handling removed?',
|
|
297
|
+
unresolved: true,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
file: 'src/Service.java',
|
|
301
|
+
range: { start_line: 20, end_line: 35 },
|
|
302
|
+
side: 'REVISION',
|
|
303
|
+
message: 'New error handling looks good, but consider extracting',
|
|
304
|
+
},
|
|
305
|
+
]),
|
|
306
|
+
)
|
|
307
|
+
}, 10)
|
|
308
|
+
|
|
309
|
+
await Effect.runPromise(program)
|
|
310
|
+
|
|
311
|
+
// Restore process.stdin
|
|
312
|
+
Object.defineProperty(process, 'stdin', {
|
|
313
|
+
value: originalStdin,
|
|
314
|
+
configurable: true,
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('should validate side parameter values', async () => {
|
|
319
|
+
const originalStdin = process.stdin
|
|
320
|
+
Object.defineProperty(process, 'stdin', {
|
|
321
|
+
value: mockProcessStdin,
|
|
322
|
+
configurable: true,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
326
|
+
|
|
327
|
+
const program = commentCommand('12345', { batch: true }).pipe(
|
|
328
|
+
Effect.provide(GerritApiServiceLive),
|
|
329
|
+
Effect.provide(mockConfigLayer),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
// Simulate invalid side value
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
mockProcessStdin.emit(
|
|
335
|
+
'data',
|
|
336
|
+
JSON.stringify([
|
|
337
|
+
{
|
|
338
|
+
file: 'src/main.js',
|
|
339
|
+
line: 10,
|
|
340
|
+
message: 'Test',
|
|
341
|
+
side: 'INVALID', // Invalid side value
|
|
342
|
+
},
|
|
343
|
+
]),
|
|
344
|
+
)
|
|
345
|
+
}, 10)
|
|
346
|
+
|
|
347
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format')
|
|
348
|
+
|
|
349
|
+
// Restore process.stdin
|
|
350
|
+
Object.defineProperty(process, 'stdin', {
|
|
351
|
+
value: originalStdin,
|
|
352
|
+
configurable: true,
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('should require either line or range but not both', async () => {
|
|
357
|
+
const originalStdin = process.stdin
|
|
358
|
+
Object.defineProperty(process, 'stdin', {
|
|
359
|
+
value: mockProcessStdin,
|
|
360
|
+
configurable: true,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
server.use(
|
|
364
|
+
http.get('*/a/changes/:changeId', () => {
|
|
365
|
+
return HttpResponse.text(`)]}'\n{
|
|
366
|
+
"id": "test-project~main~I123abc",
|
|
367
|
+
"_number": 12345,
|
|
368
|
+
"project": "test-project",
|
|
369
|
+
"branch": "main",
|
|
370
|
+
"change_id": "I123abc",
|
|
371
|
+
"subject": "Test change",
|
|
372
|
+
"status": "NEW"
|
|
373
|
+
}`)
|
|
374
|
+
}),
|
|
375
|
+
http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
|
|
376
|
+
const body = (await request.json()) as {
|
|
377
|
+
comments?: Record<string, unknown[]>
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const fileComments = body.comments?.['src/main.js'] as Array<{
|
|
381
|
+
line?: number
|
|
382
|
+
range?: unknown
|
|
383
|
+
message: string
|
|
384
|
+
}>
|
|
385
|
+
|
|
386
|
+
// Should use range when both are provided (range takes precedence)
|
|
387
|
+
expect(fileComments?.[0]).toMatchObject({
|
|
388
|
+
range: {
|
|
389
|
+
start_line: 10,
|
|
390
|
+
end_line: 15,
|
|
391
|
+
},
|
|
392
|
+
message: 'Test comment',
|
|
393
|
+
})
|
|
394
|
+
// line should NOT be included when range is present (Gerrit API preference)
|
|
395
|
+
expect(fileComments?.[0].line).toBeUndefined()
|
|
396
|
+
|
|
397
|
+
return HttpResponse.json({})
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
402
|
+
|
|
403
|
+
const program = commentCommand('12345', { batch: true }).pipe(
|
|
404
|
+
Effect.provide(GerritApiServiceLive),
|
|
405
|
+
Effect.provide(mockConfigLayer),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
// Both line and range provided - should work
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
mockProcessStdin.emit(
|
|
411
|
+
'data',
|
|
412
|
+
JSON.stringify([
|
|
413
|
+
{
|
|
414
|
+
file: 'src/main.js',
|
|
415
|
+
line: 10, // Will be included
|
|
416
|
+
range: { start_line: 10, end_line: 15 }, // Takes precedence
|
|
417
|
+
message: 'Test comment',
|
|
418
|
+
},
|
|
419
|
+
]),
|
|
420
|
+
)
|
|
421
|
+
}, 10)
|
|
422
|
+
|
|
423
|
+
await Effect.runPromise(program)
|
|
424
|
+
|
|
425
|
+
// Restore process.stdin
|
|
426
|
+
Object.defineProperty(process, 'stdin', {
|
|
427
|
+
value: originalStdin,
|
|
428
|
+
configurable: true,
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
})
|