@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,430 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process'
|
|
2
|
+
import { Console, Effect } from 'effect'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
|
|
5
|
+
import {
|
|
6
|
+
CommitHookService,
|
|
7
|
+
NotGitRepoError,
|
|
8
|
+
type HookInstallError,
|
|
9
|
+
type MissingChangeIdError,
|
|
10
|
+
type CommitHookServiceImpl,
|
|
11
|
+
} from '@/services/commit-hook'
|
|
12
|
+
|
|
13
|
+
/** Help text for push command - exported to keep index.ts under line limit */
|
|
14
|
+
export const PUSH_HELP_TEXT = `
|
|
15
|
+
Examples:
|
|
16
|
+
# Basic push to auto-detected target branch
|
|
17
|
+
$ ger push
|
|
18
|
+
|
|
19
|
+
# Push to specific branch
|
|
20
|
+
$ ger push -b master
|
|
21
|
+
$ ger push --branch feature/foo
|
|
22
|
+
|
|
23
|
+
# With topic
|
|
24
|
+
$ ger push -t my-feature
|
|
25
|
+
|
|
26
|
+
# With reviewers (can be repeated)
|
|
27
|
+
$ ger push -r alice@example.com -r bob@example.com
|
|
28
|
+
|
|
29
|
+
# With CC
|
|
30
|
+
$ ger push --cc manager@example.com
|
|
31
|
+
|
|
32
|
+
# Work in progress
|
|
33
|
+
$ ger push --wip
|
|
34
|
+
|
|
35
|
+
# Mark ready for review
|
|
36
|
+
$ ger push --ready
|
|
37
|
+
|
|
38
|
+
# Add hashtag
|
|
39
|
+
$ ger push --hashtag bugfix
|
|
40
|
+
|
|
41
|
+
# Combine options
|
|
42
|
+
$ ger push -b master -t refactor-auth -r alice@example.com --wip
|
|
43
|
+
|
|
44
|
+
# Dry run (show what would be pushed)
|
|
45
|
+
$ ger push --dry-run
|
|
46
|
+
|
|
47
|
+
Note:
|
|
48
|
+
- Auto-installs commit-msg hook if missing
|
|
49
|
+
- Auto-detects target branch from tracking branch or defaults to main/master
|
|
50
|
+
- Supports all standard Gerrit push options`
|
|
51
|
+
|
|
52
|
+
export interface PushOptions {
|
|
53
|
+
branch?: string
|
|
54
|
+
topic?: string
|
|
55
|
+
reviewer?: string[]
|
|
56
|
+
cc?: string[]
|
|
57
|
+
wip?: boolean
|
|
58
|
+
ready?: boolean
|
|
59
|
+
hashtag?: string[]
|
|
60
|
+
private?: boolean
|
|
61
|
+
draft?: boolean
|
|
62
|
+
dryRun?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Custom error for push-specific failures
|
|
66
|
+
export class PushError extends Error {
|
|
67
|
+
readonly _tag = 'PushError'
|
|
68
|
+
constructor(message: string) {
|
|
69
|
+
super(message)
|
|
70
|
+
this.name = 'PushError'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PushErrors =
|
|
75
|
+
| ConfigError
|
|
76
|
+
| HookInstallError
|
|
77
|
+
| MissingChangeIdError
|
|
78
|
+
| NotGitRepoError
|
|
79
|
+
| PushError
|
|
80
|
+
|
|
81
|
+
/** Basic email validation pattern */
|
|
82
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
83
|
+
|
|
84
|
+
/** Validate email addresses for reviewer/cc options */
|
|
85
|
+
export const validateEmails = (emails: string[] | undefined, fieldName: string): void => {
|
|
86
|
+
if (!emails) return
|
|
87
|
+
for (const email of emails) {
|
|
88
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
89
|
+
throw new PushError(
|
|
90
|
+
`Invalid email address for ${fieldName}: "${email}"\n` + `Expected format: user@domain.com`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get git remotes
|
|
97
|
+
const getGitRemotes = (): Record<string, string> => {
|
|
98
|
+
try {
|
|
99
|
+
const output = execSync('git remote -v', { encoding: 'utf8' })
|
|
100
|
+
const remotes: Record<string, string> = {}
|
|
101
|
+
|
|
102
|
+
for (const line of output.split('\n')) {
|
|
103
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\(push\)$/)
|
|
104
|
+
if (match) {
|
|
105
|
+
remotes[match[1]] = match[2]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return remotes
|
|
110
|
+
} catch {
|
|
111
|
+
return {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Find remote matching Gerrit host
|
|
116
|
+
const findMatchingRemote = (gerritHost: string): string | null => {
|
|
117
|
+
const remotes = getGitRemotes()
|
|
118
|
+
|
|
119
|
+
// Parse gerrit host
|
|
120
|
+
const gerritUrl = new URL(gerritHost)
|
|
121
|
+
const gerritHostname = gerritUrl.hostname
|
|
122
|
+
|
|
123
|
+
// Check each remote
|
|
124
|
+
for (const [name, url] of Object.entries(remotes)) {
|
|
125
|
+
try {
|
|
126
|
+
let remoteHostname: string
|
|
127
|
+
|
|
128
|
+
if (url.startsWith('git@') || url.includes('://')) {
|
|
129
|
+
if (url.startsWith('git@')) {
|
|
130
|
+
// SSH format: git@hostname:project
|
|
131
|
+
remoteHostname = url.split('@')[1].split(':')[0]
|
|
132
|
+
} else {
|
|
133
|
+
// HTTP format
|
|
134
|
+
const remoteUrl = new URL(url)
|
|
135
|
+
remoteHostname = remoteUrl.hostname
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (remoteHostname === gerritHostname) {
|
|
139
|
+
return name
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Ignore malformed URLs
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if we're in a git repo
|
|
151
|
+
const isInGitRepo = (): boolean => {
|
|
152
|
+
try {
|
|
153
|
+
execSync('git rev-parse --git-dir', { encoding: 'utf8' })
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Get current branch name
|
|
161
|
+
const getCurrentBranch = (): string | null => {
|
|
162
|
+
try {
|
|
163
|
+
const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim()
|
|
164
|
+
return branch || null
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Get tracking branch for current branch
|
|
171
|
+
const getTrackingBranch = (): string | null => {
|
|
172
|
+
try {
|
|
173
|
+
// Get the upstream branch reference
|
|
174
|
+
const upstream = execSync('git rev-parse --abbrev-ref @{upstream}', {
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
177
|
+
}).trim()
|
|
178
|
+
|
|
179
|
+
// Extract branch name (remove remote prefix like "origin/")
|
|
180
|
+
const parts = upstream.split('/')
|
|
181
|
+
if (parts.length > 1) {
|
|
182
|
+
return parts.slice(1).join('/')
|
|
183
|
+
}
|
|
184
|
+
return upstream
|
|
185
|
+
} catch {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if a remote branch exists
|
|
191
|
+
const remoteBranchExists = (remote: string, branch: string): boolean => {
|
|
192
|
+
try {
|
|
193
|
+
execSync(`git rev-parse --verify ${remote}/${branch}`, {
|
|
194
|
+
encoding: 'utf8',
|
|
195
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
196
|
+
})
|
|
197
|
+
return true
|
|
198
|
+
} catch {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Detect target branch with fallback strategy
|
|
204
|
+
const detectTargetBranch = (remote: string): string => {
|
|
205
|
+
// 1. Try tracking branch
|
|
206
|
+
const tracking = getTrackingBranch()
|
|
207
|
+
if (tracking) {
|
|
208
|
+
return tracking
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2. Check if origin/main exists
|
|
212
|
+
if (remoteBranchExists(remote, 'main')) {
|
|
213
|
+
return 'main'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 3. Check if origin/master exists
|
|
217
|
+
if (remoteBranchExists(remote, 'master')) {
|
|
218
|
+
return 'master'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 4. Final fallback
|
|
222
|
+
return 'master'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build Gerrit push refspec with options
|
|
226
|
+
export const buildPushRefspec = (branch: string, options: PushOptions): string => {
|
|
227
|
+
let refspec = `refs/for/${branch}`
|
|
228
|
+
const params: string[] = []
|
|
229
|
+
|
|
230
|
+
if (options.topic) {
|
|
231
|
+
params.push(`topic=${encodeURIComponent(options.topic)}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --draft is an alias for --wip; both map to Gerrit's 'wip' push option
|
|
235
|
+
if (options.wip || options.draft) {
|
|
236
|
+
params.push('wip')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (options.ready) {
|
|
240
|
+
params.push('ready')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (options.private) {
|
|
244
|
+
params.push('private')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (options.reviewer) {
|
|
248
|
+
for (const reviewer of options.reviewer) {
|
|
249
|
+
params.push(`r=${reviewer}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (options.cc) {
|
|
254
|
+
for (const cc of options.cc) {
|
|
255
|
+
params.push(`cc=${cc}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (options.hashtag) {
|
|
260
|
+
for (const tag of options.hashtag) {
|
|
261
|
+
params.push(`hashtag=${encodeURIComponent(tag)}`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (params.length > 0) {
|
|
266
|
+
refspec += '%' + params.join(',')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return refspec
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Parse push output to extract change URL
|
|
273
|
+
const extractChangeUrl = (output: string): string | null => {
|
|
274
|
+
// Gerrit push output format: "remote: https://gerrit.example.com/c/project/+/12345"
|
|
275
|
+
const urlMatch = output.match(/remote:\s+(https?:\/\/\S+\/c\/\S+\/\+\/\d+)/)
|
|
276
|
+
if (urlMatch) {
|
|
277
|
+
return urlMatch[1]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const pushCommand = (
|
|
284
|
+
options: PushOptions,
|
|
285
|
+
): Effect.Effect<void, PushErrors, ConfigServiceImpl | CommitHookServiceImpl> =>
|
|
286
|
+
Effect.gen(function* () {
|
|
287
|
+
// Validate email addresses early
|
|
288
|
+
yield* Effect.try({
|
|
289
|
+
try: () => {
|
|
290
|
+
validateEmails(options.reviewer, 'reviewer')
|
|
291
|
+
validateEmails(options.cc, 'cc')
|
|
292
|
+
},
|
|
293
|
+
catch: (e) => (e instanceof PushError ? e : new PushError(String(e))),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// Check if we're in a git repo
|
|
297
|
+
if (!isInGitRepo()) {
|
|
298
|
+
return yield* Effect.fail(new NotGitRepoError({ message: 'Not in a git repository' }))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Get config for Gerrit host
|
|
302
|
+
const configService = yield* ConfigService
|
|
303
|
+
const credentials = yield* configService.getCredentials
|
|
304
|
+
|
|
305
|
+
// Find matching remote
|
|
306
|
+
const remote = findMatchingRemote(credentials.host)
|
|
307
|
+
if (!remote) {
|
|
308
|
+
return yield* Effect.fail(
|
|
309
|
+
new PushError(
|
|
310
|
+
`No git remote found matching Gerrit host: ${credentials.host}\n` +
|
|
311
|
+
`Please ensure your git remote points to the Gerrit server.`,
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Ensure commit has Change-Id (installs hook if needed)
|
|
317
|
+
const commitHookService = yield* CommitHookService
|
|
318
|
+
yield* commitHookService.ensureChangeId()
|
|
319
|
+
|
|
320
|
+
// Determine target branch
|
|
321
|
+
const targetBranch = options.branch || detectTargetBranch(remote)
|
|
322
|
+
|
|
323
|
+
// Build refspec
|
|
324
|
+
const refspec = buildPushRefspec(targetBranch, options)
|
|
325
|
+
|
|
326
|
+
// Current branch info
|
|
327
|
+
const currentBranch = getCurrentBranch() || 'HEAD'
|
|
328
|
+
|
|
329
|
+
// Display what we're doing
|
|
330
|
+
if (options.dryRun) {
|
|
331
|
+
yield* Console.log(chalk.yellow('Dry run mode - no changes will be pushed\n'))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
yield* Console.log(chalk.bold('Pushing to Gerrit'))
|
|
335
|
+
yield* Console.log(` Remote: ${remote} (${credentials.host})`)
|
|
336
|
+
yield* Console.log(` Branch: ${currentBranch} -> ${targetBranch}`)
|
|
337
|
+
|
|
338
|
+
if (options.topic) {
|
|
339
|
+
yield* Console.log(` Topic: ${options.topic}`)
|
|
340
|
+
}
|
|
341
|
+
if (options.reviewer && options.reviewer.length > 0) {
|
|
342
|
+
yield* Console.log(` Reviewers: ${options.reviewer.join(', ')}`)
|
|
343
|
+
}
|
|
344
|
+
if (options.cc && options.cc.length > 0) {
|
|
345
|
+
yield* Console.log(` CC: ${options.cc.join(', ')}`)
|
|
346
|
+
}
|
|
347
|
+
if (options.wip || options.draft) {
|
|
348
|
+
yield* Console.log(` Status: ${chalk.yellow('Work-in-Progress')}`)
|
|
349
|
+
}
|
|
350
|
+
if (options.ready) {
|
|
351
|
+
yield* Console.log(` Status: ${chalk.green('Ready for Review')}`)
|
|
352
|
+
}
|
|
353
|
+
if (options.hashtag && options.hashtag.length > 0) {
|
|
354
|
+
yield* Console.log(` Hashtags: ${options.hashtag.join(', ')}`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
yield* Console.log('')
|
|
358
|
+
|
|
359
|
+
// Build git push command
|
|
360
|
+
const args = ['push']
|
|
361
|
+
if (options.dryRun) {
|
|
362
|
+
args.push('--dry-run')
|
|
363
|
+
}
|
|
364
|
+
args.push(remote)
|
|
365
|
+
args.push(`HEAD:${refspec}`)
|
|
366
|
+
|
|
367
|
+
// Execute push
|
|
368
|
+
const result = spawnSync('git', args, {
|
|
369
|
+
encoding: 'utf8',
|
|
370
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// Combine stdout and stderr (git push writes to stderr)
|
|
374
|
+
const output = (result.stdout || '') + (result.stderr || '')
|
|
375
|
+
|
|
376
|
+
if (result.status !== 0) {
|
|
377
|
+
// Parse common errors
|
|
378
|
+
if (output.includes('no new changes')) {
|
|
379
|
+
yield* Console.log(chalk.yellow('No new changes to push'))
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (output.includes('Permission denied') || output.includes('authentication failed')) {
|
|
384
|
+
return yield* Effect.fail(
|
|
385
|
+
new PushError(
|
|
386
|
+
'Authentication failed. Please check your credentials with: ger status\n' +
|
|
387
|
+
'You may need to regenerate your HTTP password in Gerrit settings.',
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (output.includes('prohibited by Gerrit')) {
|
|
393
|
+
return yield* Effect.fail(
|
|
394
|
+
new PushError(
|
|
395
|
+
'Push rejected by Gerrit. Common causes:\n' +
|
|
396
|
+
' - Missing permissions for the target branch\n' +
|
|
397
|
+
' - Branch may be read-only\n' +
|
|
398
|
+
' - Change-Id may be in use by another change',
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return yield* Effect.fail(new PushError(`Push failed:\n${output}`))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Success - try to extract change URL
|
|
407
|
+
const changeUrl = extractChangeUrl(output)
|
|
408
|
+
|
|
409
|
+
yield* Console.log(chalk.green('Push successful!'))
|
|
410
|
+
|
|
411
|
+
if (changeUrl) {
|
|
412
|
+
yield* Console.log(`\n ${chalk.cyan(changeUrl)}`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Show the raw output for additional info
|
|
416
|
+
if (output.includes('remote:')) {
|
|
417
|
+
const remoteLines = output
|
|
418
|
+
.split('\n')
|
|
419
|
+
.filter((line) => line.startsWith('remote:'))
|
|
420
|
+
.map((line) => line.replace('remote:', '').trim())
|
|
421
|
+
.filter((line) => line.length > 0)
|
|
422
|
+
|
|
423
|
+
if (remoteLines.length > 0) {
|
|
424
|
+
yield* Console.log('\nGerrit response:')
|
|
425
|
+
for (const line of remoteLines) {
|
|
426
|
+
yield* Console.log(` ${line}`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface RebaseOptions {
|
|
5
|
+
base?: string
|
|
6
|
+
xml?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rebases a Gerrit change onto the target branch or specified base.
|
|
11
|
+
*
|
|
12
|
+
* @param changeId - Change number or Change-ID to rebase
|
|
13
|
+
* @param options - Configuration options
|
|
14
|
+
* @param options.base - Optional base revision to rebase onto (default: target branch HEAD)
|
|
15
|
+
* @param options.xml - Whether to output in XML format for LLM consumption
|
|
16
|
+
* @returns Effect that completes when the change is rebased
|
|
17
|
+
*/
|
|
18
|
+
export const rebaseCommand = (
|
|
19
|
+
changeId?: string,
|
|
20
|
+
options: RebaseOptions = {},
|
|
21
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const gerritApi = yield* GerritApiService
|
|
24
|
+
|
|
25
|
+
if (!changeId || changeId.trim() === '') {
|
|
26
|
+
console.error('✗ Change ID is required')
|
|
27
|
+
console.error(' Usage: ger rebase <change-id> [--base <ref>]')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Perform the rebase - this returns the rebased change info
|
|
32
|
+
const change = yield* gerritApi.rebaseChange(changeId, { base: options.base })
|
|
33
|
+
|
|
34
|
+
if (options.xml) {
|
|
35
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
36
|
+
console.log(`<rebase_result>`)
|
|
37
|
+
console.log(` <status>success</status>`)
|
|
38
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
39
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
40
|
+
console.log(` <branch>${change.branch}</branch>`)
|
|
41
|
+
if (options.base) {
|
|
42
|
+
console.log(` <base><![CDATA[${options.base}]]></base>`)
|
|
43
|
+
}
|
|
44
|
+
console.log(`</rebase_result>`)
|
|
45
|
+
} else {
|
|
46
|
+
console.log(`✓ Rebased change ${change._number}: ${change.subject}`)
|
|
47
|
+
console.log(` Branch: ${change.branch}`)
|
|
48
|
+
if (options.base) {
|
|
49
|
+
console.log(` Base: ${options.base}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
|
|
4
|
+
|
|
5
|
+
interface RemoveReviewerOptions {
|
|
6
|
+
change?: string
|
|
7
|
+
notify?: string
|
|
8
|
+
xml?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
|
|
12
|
+
|
|
13
|
+
const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
|
|
14
|
+
|
|
15
|
+
const isValidNotifyLevel = (value: string): value is NotifyLevel =>
|
|
16
|
+
VALID_NOTIFY_LEVELS.some((level) => level === value)
|
|
17
|
+
|
|
18
|
+
const outputXmlError = (message: string): void => {
|
|
19
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
20
|
+
console.log(`<remove_reviewer_result>`)
|
|
21
|
+
console.log(` <status>error</status>`)
|
|
22
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(message)}]]></error>`)
|
|
23
|
+
console.log(`</remove_reviewer_result>`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ValidationError extends Error {
|
|
27
|
+
readonly _tag = 'ValidationError'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const removeReviewerCommand = (
|
|
31
|
+
reviewers: string[],
|
|
32
|
+
options: RemoveReviewerOptions = {},
|
|
33
|
+
): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const gerritApi = yield* GerritApiService
|
|
36
|
+
|
|
37
|
+
const changeId = options.change
|
|
38
|
+
|
|
39
|
+
if (!changeId) {
|
|
40
|
+
const message =
|
|
41
|
+
'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
|
|
42
|
+
if (options.xml) {
|
|
43
|
+
outputXmlError(message)
|
|
44
|
+
} else {
|
|
45
|
+
console.error(`✗ ${message}`)
|
|
46
|
+
}
|
|
47
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (reviewers.length === 0) {
|
|
51
|
+
const message = 'At least one reviewer is required.'
|
|
52
|
+
if (options.xml) {
|
|
53
|
+
outputXmlError(message)
|
|
54
|
+
} else {
|
|
55
|
+
console.error(`✗ ${message}`)
|
|
56
|
+
}
|
|
57
|
+
return yield* Effect.fail(new ValidationError(message))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let notify: NotifyLevel | undefined
|
|
61
|
+
if (options.notify) {
|
|
62
|
+
const upperNotify = options.notify.toUpperCase()
|
|
63
|
+
if (!isValidNotifyLevel(upperNotify)) {
|
|
64
|
+
const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
|
|
65
|
+
if (options.xml) {
|
|
66
|
+
outputXmlError(message)
|
|
67
|
+
} else {
|
|
68
|
+
console.error(`✗ ${message}`)
|
|
69
|
+
}
|
|
70
|
+
yield* Effect.fail(new ValidationError(message))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
notify = upperNotify
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const results: Array<{ reviewer: string; success: boolean; error?: string }> = []
|
|
77
|
+
|
|
78
|
+
for (const reviewer of reviewers) {
|
|
79
|
+
const result = yield* Effect.either(
|
|
80
|
+
gerritApi.removeReviewer(changeId, reviewer, notify ? { notify } : undefined),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (result._tag === 'Left') {
|
|
84
|
+
const error = result.left
|
|
85
|
+
const message = 'message' in error ? String(error.message) : String(error)
|
|
86
|
+
results.push({ reviewer, success: false, error: message })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
results.push({ reviewer, success: true })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.xml) {
|
|
94
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
95
|
+
console.log(`<remove_reviewer_result>`)
|
|
96
|
+
console.log(` <change_id>${escapeXML(changeId)}</change_id>`)
|
|
97
|
+
console.log(` <reviewers>`)
|
|
98
|
+
for (const r of results) {
|
|
99
|
+
if (r.success) {
|
|
100
|
+
console.log(` <reviewer status="removed">`)
|
|
101
|
+
console.log(` <input>${escapeXML(r.reviewer)}</input>`)
|
|
102
|
+
console.log(` </reviewer>`)
|
|
103
|
+
} else {
|
|
104
|
+
console.log(` <reviewer status="failed">`)
|
|
105
|
+
console.log(` <input>${escapeXML(r.reviewer)}</input>`)
|
|
106
|
+
console.log(` <error><![CDATA[${sanitizeCDATA(r.error ?? '')}]]></error>`)
|
|
107
|
+
console.log(` </reviewer>`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
console.log(` </reviewers>`)
|
|
111
|
+
const allSuccess = results.every((r) => r.success)
|
|
112
|
+
console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
|
|
113
|
+
console.log(`</remove_reviewer_result>`)
|
|
114
|
+
} else {
|
|
115
|
+
for (const r of results) {
|
|
116
|
+
if (r.success) {
|
|
117
|
+
console.log(`✓ Removed ${r.reviewer}`)
|
|
118
|
+
} else {
|
|
119
|
+
console.error(`✗ Failed to remove ${r.reviewer}: ${r.error}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface RestoreOptions {
|
|
5
|
+
message?: string
|
|
6
|
+
xml?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Restores an abandoned Gerrit change to NEW status.
|
|
11
|
+
*
|
|
12
|
+
* @param changeId - Change number or Change-ID to restore
|
|
13
|
+
* @param options - Configuration options
|
|
14
|
+
* @param options.message - Optional restoration message
|
|
15
|
+
* @param options.xml - Whether to output in XML format for LLM consumption
|
|
16
|
+
* @returns Effect that completes when the change is restored
|
|
17
|
+
*/
|
|
18
|
+
export const restoreCommand = (
|
|
19
|
+
changeId?: string,
|
|
20
|
+
options: RestoreOptions = {},
|
|
21
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const gerritApi = yield* GerritApiService
|
|
24
|
+
|
|
25
|
+
if (!changeId || changeId.trim() === '') {
|
|
26
|
+
console.error('✗ Change ID is required')
|
|
27
|
+
console.error(' Usage: ger restore <change-id>')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Perform the restore - this returns the restored change info
|
|
32
|
+
const change = yield* gerritApi.restoreChange(changeId, options.message)
|
|
33
|
+
|
|
34
|
+
if (options.xml) {
|
|
35
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
36
|
+
console.log(`<restore_result>`)
|
|
37
|
+
console.log(` <status>success</status>`)
|
|
38
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
39
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
40
|
+
if (options.message) {
|
|
41
|
+
console.log(` <message><![CDATA[${options.message}]]></message>`)
|
|
42
|
+
}
|
|
43
|
+
console.log(`</restore_result>`)
|
|
44
|
+
} else {
|
|
45
|
+
console.log(`✓ Restored change ${change._number}: ${change.subject}`)
|
|
46
|
+
if (options.message) {
|
|
47
|
+
console.log(` Message: ${options.message}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|