@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.
- package/EXAMPLES.md +48 -0
- package/README.md +87 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +28 -0
- package/src/cli/commands/add-reviewer.ts +135 -0
- package/src/cli/commands/build-status.ts +69 -25
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/show.ts +20 -0
- package/src/cli/index.ts +115 -74
- package/src/schemas/gerrit.ts +43 -0
- package/src/services/commit-hook.ts +314 -0
- package/tests/add-reviewer.test.ts +393 -0
- package/tests/build-status-watch.test.ts +8 -11
- package/tests/build-status.test.ts +149 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/search.test.ts +712 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
|
@@ -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
|
+
})
|
package/src/cli/commands/show.ts
CHANGED
|
@@ -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
|