@aaronshaf/ger 0.3.1 → 0.3.3

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.
@@ -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,162 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { ChangeInfo } from '@/schemas/gerrit'
4
+ import { colors, formatDate } from '@/utils/formatters'
5
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
6
+ import { getStatusIndicators } from '@/utils/status-indicators'
7
+
8
+ export const SEARCH_HELP_TEXT = `
9
+ Examples:
10
+ # Search for all open changes (default)
11
+ $ ger search
12
+
13
+ # Search for your open changes
14
+ $ ger search "owner:self status:open"
15
+
16
+ # Search for changes by a specific user
17
+ $ ger search "owner:john@example.com"
18
+
19
+ # Search by project
20
+ $ ger search "project:my-project status:open"
21
+
22
+ # Search with date filters
23
+ $ ger search "owner:self after:2025-01-01"
24
+ $ ger search "status:merged age:7d"
25
+
26
+ # Combine filters
27
+ $ ger search "owner:self status:merged before:2025-06-01"
28
+
29
+ # Limit results
30
+ $ ger search "project:my-project" -n 10
31
+
32
+ Common query operators:
33
+ owner:USER Changes owned by USER (use 'self' for yourself)
34
+ status:STATE open, merged, abandoned, closed
35
+ project:NAME Changes in a specific project
36
+ branch:NAME Changes targeting a branch
37
+ age:TIME Time since last update (e.g., 1d, 2w, 1mon)
38
+ before:DATE Changes modified before date (YYYY-MM-DD)
39
+ after:DATE Changes modified after date (YYYY-MM-DD)
40
+ is:wip Work-in-progress changes
41
+ is:submittable Changes ready to submit
42
+ reviewer:USER Changes where USER is a reviewer
43
+ label:NAME=VALUE Filter by label (e.g., label:Code-Review+2)
44
+
45
+ Full query syntax: https://gerrit-review.googlesource.com/Documentation/user-search.html`
46
+
47
+ interface SearchOptions {
48
+ xml?: boolean
49
+ limit?: string
50
+ }
51
+
52
+ // Group changes by project for better organization
53
+ const groupChangesByProject = (changes: readonly ChangeInfo[]) => {
54
+ const grouped = new Map<string, ChangeInfo[]>()
55
+
56
+ for (const change of changes) {
57
+ const project = change.project
58
+ const existing = grouped.get(project) ?? []
59
+ existing.push(change)
60
+ grouped.set(project, existing)
61
+ }
62
+
63
+ // Sort projects alphabetically and changes by updated date
64
+ return Array.from(grouped.entries())
65
+ .sort(([a], [b]) => a.localeCompare(b))
66
+ .map(([project, projectChanges]) => ({
67
+ project,
68
+ changes: projectChanges.sort((a, b) => {
69
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
70
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
71
+ return dateB - dateA
72
+ }),
73
+ }))
74
+ }
75
+
76
+ export const searchCommand = (
77
+ query: string | undefined,
78
+ options: SearchOptions,
79
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
80
+ Effect.gen(function* () {
81
+ const gerritApi = yield* GerritApiService
82
+
83
+ // Build the final query with limit if specified
84
+ let finalQuery = query || 'is:open'
85
+ const parsedLimit = options.limit ? parseInt(options.limit, 10) : 25
86
+ const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 25 : parsedLimit
87
+ if (!finalQuery.includes('limit:')) {
88
+ finalQuery = `${finalQuery} limit:${limit}`
89
+ }
90
+
91
+ const changes = yield* gerritApi.listChanges(finalQuery)
92
+
93
+ // Group changes by project (used by both output formats)
94
+ const groupedChanges = changes.length > 0 ? groupChangesByProject(changes) : []
95
+
96
+ if (options.xml) {
97
+ // XML output
98
+ const xmlOutput = [
99
+ '<?xml version="1.0" encoding="UTF-8"?>',
100
+ '<search_results>',
101
+ ` <query><![CDATA[${sanitizeCDATA(finalQuery)}]]></query>`,
102
+ ` <count>${changes.length}</count>`,
103
+ ]
104
+
105
+ if (changes.length > 0) {
106
+ xmlOutput.push(' <changes>')
107
+
108
+ for (const { project, changes: projectChanges } of groupedChanges) {
109
+ xmlOutput.push(` <project name="${escapeXML(project)}">`)
110
+ for (const change of projectChanges) {
111
+ xmlOutput.push(' <change>')
112
+ xmlOutput.push(` <number>${change._number}</number>`)
113
+ xmlOutput.push(
114
+ ` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`,
115
+ )
116
+ xmlOutput.push(` <status>${escapeXML(change.status)}</status>`)
117
+ xmlOutput.push(` <owner>${escapeXML(change.owner?.name ?? 'Unknown')}</owner>`)
118
+ xmlOutput.push(` <branch>${escapeXML(change.branch)}</branch>`)
119
+ if (change.updated && change.updated.trim() !== '') {
120
+ xmlOutput.push(` <updated>${escapeXML(change.updated)}</updated>`)
121
+ }
122
+ if (change.owner?.email) {
123
+ xmlOutput.push(` <owner_email>${escapeXML(change.owner.email)}</owner_email>`)
124
+ }
125
+ xmlOutput.push(' </change>')
126
+ }
127
+ xmlOutput.push(' </project>')
128
+ }
129
+
130
+ xmlOutput.push(' </changes>')
131
+ }
132
+
133
+ xmlOutput.push('</search_results>')
134
+ console.log(xmlOutput.join('\n'))
135
+ } else {
136
+ // Pretty output (default)
137
+ if (changes.length === 0) {
138
+ console.log(`${colors.yellow}No changes found${colors.reset}`)
139
+ return
140
+ }
141
+
142
+ console.log(`${colors.blue}Search results (${changes.length})${colors.reset}\n`)
143
+
144
+ for (const { project, changes: projectChanges } of groupedChanges) {
145
+ console.log(`${colors.gray}${project}${colors.reset}`)
146
+
147
+ for (const change of projectChanges) {
148
+ const indicators = getStatusIndicators(change)
149
+ const statusPart = indicators.length > 0 ? `${indicators.join(' ')} ` : ''
150
+ const dateStr = change.updated ? ` • ${formatDate(change.updated)}` : ''
151
+
152
+ console.log(
153
+ ` ${statusPart}${colors.yellow}#${change._number}${colors.reset} ${change.subject}`,
154
+ )
155
+ console.log(
156
+ ` ${colors.gray}by ${change.owner?.name ?? 'Unknown'} • ${change.status}${dateStr}${colors.reset}`,
157
+ )
158
+ }
159
+ console.log() // Empty line between projects
160
+ }
161
+ }
162
+ })
@@ -9,6 +9,26 @@ import { formatDate } from '@/utils/formatters'
9
9
  import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
10
  import { writeFileSync } from 'node:fs'
11
11
 
12
+ export const SHOW_HELP_TEXT = `
13
+ Examples:
14
+ # Show specific change (using change number)
15
+ $ ger show 392385
16
+
17
+ # Show specific change (using Change-ID)
18
+ $ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
19
+
20
+ # Auto-detect Change-ID from HEAD commit
21
+ $ ger show
22
+ $ ger show --xml
23
+ $ ger show --json
24
+
25
+ # Extract build failure URL with jq
26
+ $ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
27
+
28
+ Note: When no change-id is provided, it will be automatically extracted from the
29
+ Change-ID footer in your HEAD commit. You must be in a git repository with
30
+ a commit that has a Change-ID.`
31
+
12
32
  interface ShowOptions {
13
33
  xml?: boolean
14
34
  json?: boolean