@aaronshaf/ger 3.0.2 → 4.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.
@@ -0,0 +1,268 @@
1
+ import { execSync, spawnSync } from 'node:child_process'
2
+ import { Console, Effect, Schema } from 'effect'
3
+ import chalk from 'chalk'
4
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
5
+ import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
6
+ import { extractChangeNumber } from '@/utils/url-parser'
7
+
8
+ export const CHERRY_HELP_TEXT = `
9
+ Examples:
10
+ # Cherry-pick latest patchset
11
+ $ ger cherry 12345
12
+
13
+ # Cherry-pick a specific patchset
14
+ $ ger cherry 12345/3
15
+
16
+ # Cherry-pick by Change-ID
17
+ $ ger cherry If5a3ae8cb5a107e187447802358417f311d0c4b1
18
+
19
+ # Cherry-pick from URL
20
+ $ ger cherry https://gerrit.example.com/c/my-project/+/12345
21
+
22
+ # Stage changes without committing
23
+ $ ger cherry 12345 --no-commit
24
+
25
+ # Use a specific remote
26
+ $ ger cherry 12345 --remote upstream
27
+
28
+ Notes:
29
+ - Fetches the change then runs git cherry-pick FETCH_HEAD
30
+ - Use --no-commit to stage without committing (git cherry-pick -n)`
31
+
32
+ export interface CherryOptions {
33
+ noCommit?: boolean
34
+ remote?: string
35
+ noVerify?: boolean
36
+ }
37
+
38
+ class CherryError extends Error {
39
+ readonly _tag = 'CherryError' as const
40
+ constructor(message: string) {
41
+ super(message)
42
+ this.name = 'CherryError'
43
+ }
44
+ }
45
+
46
+ class NotGitRepoError extends Error {
47
+ readonly _tag = 'NotGitRepoError' as const
48
+ constructor(message: string) {
49
+ super(message)
50
+ this.name = 'NotGitRepoError'
51
+ }
52
+ }
53
+
54
+ class PatchsetNotFoundError extends Error {
55
+ readonly _tag = 'PatchsetNotFoundError' as const
56
+ constructor(public readonly patchset: number) {
57
+ super(`Patchset ${patchset} not found`)
58
+ this.name = 'PatchsetNotFoundError'
59
+ }
60
+ }
61
+
62
+ class InvalidInputError extends Error {
63
+ readonly _tag = 'InvalidInputError' as const
64
+ constructor(message: string) {
65
+ super(message)
66
+ this.name = 'InvalidInputError'
67
+ }
68
+ }
69
+
70
+ export type CherryErrors =
71
+ | ConfigError
72
+ | ApiError
73
+ | CherryError
74
+ | NotGitRepoError
75
+ | PatchsetNotFoundError
76
+ | InvalidInputError
77
+
78
+ // Git-safe string validation — prevents command injection
79
+ const GitSafeString = Schema.String.pipe(
80
+ Schema.pattern(/^[a-zA-Z0-9_\-/.]+$/),
81
+ Schema.annotations({ message: () => 'Invalid characters in git identifier' }),
82
+ )
83
+
84
+ // Gerrit ref validation (refs/changes/xx/xxxxx/x)
85
+ const GerritRef = Schema.String.pipe(
86
+ Schema.pattern(/^refs\/changes\/\d{2}\/\d+\/\d+$/),
87
+ Schema.annotations({ message: () => 'Invalid Gerrit ref format' }),
88
+ )
89
+
90
+ const validateGitSafe = (
91
+ value: string,
92
+ fieldName: string,
93
+ ): Effect.Effect<string, InvalidInputError> =>
94
+ Schema.decodeUnknown(GitSafeString)(value).pipe(
95
+ Effect.mapError(() => {
96
+ const sanitized = value.length > 20 ? `${value.substring(0, 20)}...` : value
97
+ return new InvalidInputError(`${fieldName} contains invalid characters: ${sanitized}`)
98
+ }),
99
+ )
100
+
101
+ const validateGerritRef = (value: string): Effect.Effect<string, InvalidInputError> =>
102
+ Schema.decodeUnknown(GerritRef)(value).pipe(
103
+ Effect.mapError(() => {
104
+ const sanitized = value.length > 30 ? `${value.substring(0, 30)}...` : value
105
+ return new InvalidInputError(`Invalid Gerrit ref format: ${sanitized}`)
106
+ }),
107
+ )
108
+
109
+ interface ParsedChange {
110
+ changeId: string
111
+ patchset?: number
112
+ }
113
+
114
+ const parseChangeInput = (input: string): ParsedChange => {
115
+ const trimmed = input.trim()
116
+
117
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
118
+ const changeId = extractChangeNumber(trimmed)
119
+ const patchsetMatch = trimmed.match(/\/\d+\/(\d+)(?:\/|$)/)
120
+ if (patchsetMatch?.[1]) {
121
+ return { changeId, patchset: parseInt(patchsetMatch[1], 10) }
122
+ }
123
+ return { changeId }
124
+ }
125
+
126
+ if (trimmed.includes('/') && !trimmed.startsWith('http')) {
127
+ const parts = trimmed.split('/')
128
+ if (parts.length === 2) {
129
+ const [changeId, patchsetStr] = parts
130
+ const patchset = parseInt(patchsetStr, 10)
131
+ if (!Number.isNaN(patchset) && patchset > 0) {
132
+ return { changeId, patchset }
133
+ }
134
+ return { changeId }
135
+ }
136
+ }
137
+
138
+ return { changeId: trimmed }
139
+ }
140
+
141
+ const getGitRemotes = (): Record<string, string> => {
142
+ try {
143
+ const output = execSync('git remote -v', { encoding: 'utf8', timeout: 5000 })
144
+ const remotes: Record<string, string> = {}
145
+ for (const line of output.split('\n')) {
146
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
147
+ if (match) remotes[match[1]] = match[2]
148
+ }
149
+ return remotes
150
+ } catch {
151
+ return {}
152
+ }
153
+ }
154
+
155
+ const findMatchingRemote = (gerritHost: string): string | null => {
156
+ const remotes = getGitRemotes()
157
+ const gerritHostname = new URL(gerritHost).hostname
158
+ for (const [name, url] of Object.entries(remotes)) {
159
+ try {
160
+ let remoteHostname: string
161
+ if (url.startsWith('git@')) {
162
+ remoteHostname = url.split('@')[1].split(':')[0]
163
+ } else if (url.includes('://')) {
164
+ remoteHostname = new URL(url).hostname
165
+ } else {
166
+ continue
167
+ }
168
+ if (remoteHostname === gerritHostname) return name
169
+ } catch {
170
+ // ignore malformed URLs
171
+ }
172
+ }
173
+ return null
174
+ }
175
+
176
+ const isInGitRepo = (): boolean => {
177
+ try {
178
+ execSync('git rev-parse --git-dir', { encoding: 'utf8', timeout: 5000 })
179
+ return true
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
185
+ export const cherryCommand = (
186
+ changeInput: string,
187
+ options: CherryOptions,
188
+ ): Effect.Effect<void, CherryErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
189
+ Effect.gen(function* () {
190
+ const parsed = parseChangeInput(changeInput)
191
+
192
+ if (!isInGitRepo()) {
193
+ return yield* Effect.fail(new NotGitRepoError('Not in a git repository'))
194
+ }
195
+
196
+ const configService = yield* ConfigService
197
+ const apiService = yield* GerritApiService
198
+ const credentials = yield* configService.getCredentials
199
+
200
+ const change = yield* apiService.getChange(parsed.changeId)
201
+
202
+ const revision = yield* Effect.gen(function* () {
203
+ if (parsed.patchset !== undefined) {
204
+ const patchsetNum = parsed.patchset
205
+ return yield* apiService
206
+ .getRevision(parsed.changeId, patchsetNum.toString())
207
+ .pipe(Effect.catchAll(() => Effect.fail(new PatchsetNotFoundError(patchsetNum))))
208
+ }
209
+
210
+ if (change.current_revision && change.revisions) {
211
+ const currentRevision = change.revisions[change.current_revision]
212
+ if (currentRevision) return currentRevision
213
+ }
214
+
215
+ return yield* apiService.getRevision(parsed.changeId, 'current')
216
+ })
217
+
218
+ const validatedRef = yield* validateGerritRef(revision.ref)
219
+ const rawRemote = options.remote ?? findMatchingRemote(credentials.host) ?? 'origin'
220
+ const remote = yield* validateGitSafe(rawRemote, 'remote')
221
+
222
+ yield* Console.log(chalk.bold('Cherry-picking Gerrit change'))
223
+ yield* Console.log(` Change: ${chalk.cyan(String(change._number))} — ${change.subject}`)
224
+ yield* Console.log(` Patchset: ${revision._number}`)
225
+ yield* Console.log(` Branch: ${change.branch}`)
226
+ yield* Console.log(` Remote: ${remote}`)
227
+ yield* Console.log('')
228
+
229
+ yield* Console.log(chalk.dim(`Fetching ${validatedRef}...`))
230
+ yield* Effect.try({
231
+ try: () => {
232
+ const result = spawnSync('git', ['fetch', remote, validatedRef], {
233
+ stdio: 'inherit',
234
+ timeout: 60000,
235
+ })
236
+ if (result.status !== 0) throw new Error(result.stderr?.toString() ?? 'fetch failed')
237
+ },
238
+ catch: (e) =>
239
+ new CherryError(`Failed to fetch: ${e instanceof Error ? e.message : String(e)}`),
240
+ })
241
+
242
+ const cherryPickCmd = [
243
+ 'cherry-pick',
244
+ ...(options.noCommit ? ['-n'] : []),
245
+ ...(options.noVerify ? ['--no-verify'] : []),
246
+ 'FETCH_HEAD',
247
+ ]
248
+ yield* Console.log(
249
+ chalk.dim(`Running git cherry-pick ${options.noCommit ? '-n ' : ''}FETCH_HEAD...`),
250
+ )
251
+ yield* Effect.try({
252
+ try: () => {
253
+ const result = spawnSync('git', cherryPickCmd, { stdio: 'inherit', timeout: 60000 })
254
+ if (result.status !== 0) throw new Error(result.stderr?.toString() ?? 'cherry-pick failed')
255
+ },
256
+ catch: (e) =>
257
+ new CherryError(`Cherry-pick failed: ${e instanceof Error ? e.message : String(e)}`),
258
+ })
259
+
260
+ yield* Console.log('')
261
+ if (options.noCommit) {
262
+ yield* Console.log(
263
+ chalk.green('✓ Changes staged (not committed). Review then run git commit.'),
264
+ )
265
+ } else {
266
+ yield* Console.log(chalk.green(`✓ Cherry-picked change ${change._number} successfully`))
267
+ }
268
+ })
@@ -0,0 +1,74 @@
1
+ import { Console, Effect } from 'effect'
2
+ import type { MessageInfo } from '@/schemas/gerrit'
3
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
4
+ import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
5
+
6
+ export interface FailuresOptions {
7
+ json?: boolean
8
+ xml?: boolean
9
+ }
10
+
11
+ export type FailuresErrors = ConfigError | ApiError
12
+
13
+ const JENKINS_LINK_RE =
14
+ /https:\/\/jenkins\.inst-ci\.net\/job\/Canvas\/job\/[^/]+\/\d+\/\/build-summary-report\//
15
+
16
+ const isServiceCloudJenkins = (msg: MessageInfo): boolean => {
17
+ const author = msg.author
18
+ if (!author) return false
19
+ const name = (author.name ?? author.username ?? author.email ?? '').toLowerCase()
20
+ return name.includes('service cloud jenkins')
21
+ }
22
+
23
+ const findMostRecentFailureLink = (messages: readonly MessageInfo[]): string | null => {
24
+ for (let i = messages.length - 1; i >= 0; i--) {
25
+ const msg = messages[i]
26
+ if (!isServiceCloudJenkins(msg)) continue
27
+ if (!msg.message.includes('Verified-1')) continue
28
+ const match = JENKINS_LINK_RE.exec(msg.message)
29
+ if (match) return match[0]
30
+ }
31
+ return null
32
+ }
33
+
34
+ export const failuresCommand = (
35
+ changeId: string,
36
+ options: FailuresOptions,
37
+ ): Effect.Effect<void, FailuresErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
38
+ Effect.gen(function* () {
39
+ const _config = yield* ConfigService
40
+ const api = yield* GerritApiService
41
+
42
+ const messages = yield* api.getMessages(changeId)
43
+ const link = findMostRecentFailureLink(messages)
44
+
45
+ if (!link) {
46
+ if (options.json) {
47
+ yield* Console.log(JSON.stringify({ status: 'not_found', change_id: changeId }, null, 2))
48
+ } else if (options.xml) {
49
+ yield* Console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
50
+ yield* Console.log(`<failures>`)
51
+ yield* Console.log(` <status>not_found</status>`)
52
+ yield* Console.log(` <change_id>${changeId}</change_id>`)
53
+ yield* Console.log(`</failures>`)
54
+ } else {
55
+ yield* Console.log('No build failure links found from Service Cloud Jenkins')
56
+ }
57
+ return
58
+ }
59
+
60
+ if (options.json) {
61
+ yield* Console.log(
62
+ JSON.stringify({ status: 'found', change_id: changeId, url: link }, null, 2),
63
+ )
64
+ } else if (options.xml) {
65
+ yield* Console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
66
+ yield* Console.log(`<failures>`)
67
+ yield* Console.log(` <status>found</status>`)
68
+ yield* Console.log(` <change_id>${changeId}</change_id>`)
69
+ yield* Console.log(` <url>${link}</url>`)
70
+ yield* Console.log(`</failures>`)
71
+ } else {
72
+ yield* Console.log(link)
73
+ }
74
+ })
@@ -0,0 +1,239 @@
1
+ import { Effect } from 'effect'
2
+ import chalk from 'chalk'
3
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
4
+ import type { ChangeInfo } from '@/schemas/gerrit'
5
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
6
+
7
+ export interface ListOptions {
8
+ status?: string
9
+ limit?: number
10
+ detailed?: boolean
11
+ reviewer?: boolean
12
+ allVerified?: boolean
13
+ filter?: string
14
+ json?: boolean
15
+ xml?: boolean
16
+ }
17
+
18
+ type LabelInfo = NonNullable<ChangeInfo['labels']>[string]
19
+
20
+ // ── Label score helpers ────────────────────────────────────────────────────
21
+
22
+ const getLabelScore = (label: LabelInfo): number | null => {
23
+ if (label.approved) return 2
24
+ if (label.rejected) return -2
25
+ if (label.recommended) return 1
26
+ if (label.disliked) return -1
27
+ if (label.value !== undefined && label.value !== 0) return label.value
28
+ return null
29
+ }
30
+
31
+ const fmtCR = (label: LabelInfo | undefined): string => {
32
+ if (!label) return chalk.gray('—')
33
+ const s = getLabelScore(label)
34
+ if (s === null || s === 0) return chalk.gray('0')
35
+ if (s >= 2) return chalk.bold.green('+2')
36
+ if (s === 1) return chalk.cyan('+1')
37
+ if (s === -1) return chalk.yellow('-1')
38
+ return chalk.bold.red('-2')
39
+ }
40
+
41
+ const fmtVerified = (label: LabelInfo | undefined): string => {
42
+ if (!label) return chalk.gray('—')
43
+ const s = getLabelScore(label)
44
+ if (s === null || s === 0) return chalk.gray('—')
45
+ if (s > 0) return chalk.green('V+')
46
+ return chalk.red('V-')
47
+ }
48
+
49
+ const fmtLabel = (label: LabelInfo | undefined): string => {
50
+ if (!label) return chalk.gray('—')
51
+ const s = getLabelScore(label)
52
+ if (s === null || s === 0) return chalk.gray('—')
53
+ if (s > 0) return chalk.green(`+${s}`)
54
+ return chalk.red(String(s))
55
+ }
56
+
57
+ // ── Time-ago ───────────────────────────────────────────────────────────────
58
+
59
+ const timeAgo = (dateStr: string): string => {
60
+ const ms = Date.now() - new Date(dateStr.replace(' ', 'T').split('.')[0] + 'Z').getTime()
61
+ const mins = Math.floor(ms / 60000)
62
+ if (mins < 60) return `${mins}m ago`
63
+ const hrs = Math.floor(mins / 60)
64
+ if (hrs < 24) return `${hrs}h ago`
65
+ const days = Math.floor(hrs / 24)
66
+ if (days < 14) return `${days}d ago`
67
+ const weeks = Math.floor(days / 7)
68
+ if (weeks < 8) return `${weeks}w ago`
69
+ return dateStr.slice(0, 10)
70
+ }
71
+
72
+ // ── Table rendering ────────────────────────────────────────────────────────
73
+
74
+ const COL_CHANGE = 8
75
+ const COL_SUBJECT_MINE = 58
76
+ const COL_SUBJECT_TEAM = 45
77
+ const COL_OWNER = 20
78
+ const COL_SCORE = 4
79
+ const COL_UPDATED = 10
80
+
81
+ const pad = (s: string, width: number): string => {
82
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '')
83
+ const extra = s.length - visible.length
84
+ return s.padEnd(width + extra)
85
+ }
86
+
87
+ const truncate = (s: string, max: number): string =>
88
+ s.length > max ? `${s.slice(0, max - 1)}…` : s
89
+
90
+ const getOwnerLabel = (change: ChangeInfo): string =>
91
+ change.owner?.name ?? change.owner?.email ?? String(change.owner?._account_id ?? '—')
92
+
93
+ const renderTableHeader = (showOwner: boolean): void => {
94
+ const h = chalk.bold
95
+ const colSubject = showOwner ? COL_SUBJECT_TEAM : COL_SUBJECT_MINE
96
+ const ownerCol = showOwner ? ` ${h(pad('Owner', COL_OWNER))}` : ''
97
+ console.log(
98
+ ` ${h(pad('Change', COL_CHANGE))} ${h(pad('Subject', colSubject))}${ownerCol} ` +
99
+ `${h(pad('CR', COL_SCORE))} ${h(pad('QR', COL_SCORE))} ` +
100
+ `${h(pad('LR', COL_SCORE))} ${h(pad('Verified', 8))} ${h('Updated')}`,
101
+ )
102
+ const d = '─'
103
+ const ownerDiv = showOwner ? ` ${d.repeat(COL_OWNER)}` : ''
104
+ console.log(
105
+ ` ${d.repeat(COL_CHANGE)} ${d.repeat(colSubject)}${ownerDiv} ` +
106
+ `${d.repeat(COL_SCORE)} ${d.repeat(COL_SCORE)} ` +
107
+ `${d.repeat(COL_SCORE)} ${d.repeat(8)} ${d.repeat(COL_UPDATED)}`,
108
+ )
109
+ }
110
+
111
+ const renderTableRow = (change: ChangeInfo, showOwner: boolean): void => {
112
+ const colSubject = showOwner ? COL_SUBJECT_TEAM : COL_SUBJECT_MINE
113
+ const num = chalk.cyan(pad(String(change._number), COL_CHANGE))
114
+ const subject = pad(truncate(change.subject, colSubject), colSubject)
115
+ const ownerCol = showOwner
116
+ ? ` ${pad(truncate(getOwnerLabel(change), COL_OWNER), COL_OWNER)}`
117
+ : ''
118
+ const cr = pad(fmtCR(change.labels?.['Code-Review']), COL_SCORE)
119
+ const qr = pad(fmtLabel(change.labels?.['QA-Review']), COL_SCORE)
120
+ const lr = pad(fmtLabel(change.labels?.['Lint-Review']), COL_SCORE)
121
+ const verified = pad(fmtVerified(change.labels?.['Verified']), 8)
122
+ const updated = timeAgo(change.updated ?? change.created ?? '')
123
+ console.log(` ${num} ${subject}${ownerCol} ${cr} ${qr} ${lr} ${verified} ${updated}`)
124
+ }
125
+
126
+ const renderDetailed = (change: ChangeInfo): void => {
127
+ console.log(`${chalk.bold.cyan('Change:')} ${chalk.bold(String(change._number))}`)
128
+ console.log(`${chalk.bold.cyan('Subject:')} ${change.subject}`)
129
+ console.log(`${chalk.bold.cyan('Status:')} ${change.status}`)
130
+ console.log(`${chalk.bold.cyan('Project:')} ${change.project}`)
131
+ console.log(`${chalk.bold.cyan('Branch:')} ${change.branch}`)
132
+ if (change.owner?.name) console.log(`${chalk.bold.cyan('Owner:')} ${change.owner.name}`)
133
+ if (change.updated) console.log(`${chalk.bold.cyan('Updated:')} ${timeAgo(change.updated)}`)
134
+
135
+ const labels = change.labels
136
+ if (labels && Object.keys(labels).length > 0) {
137
+ const scores = Object.entries(labels)
138
+ .map(([name, info]) => {
139
+ const s = getLabelScore(info)
140
+ if (s === null) return null
141
+ const formatted = s > 0 ? chalk.green(`+${s}`) : chalk.red(String(s))
142
+ return `${name}:${formatted}`
143
+ })
144
+ .filter((x): x is string => x !== null)
145
+ if (scores.length > 0) {
146
+ console.log(`${chalk.bold.cyan('Reviews:')} ${scores.join(' ')}`)
147
+ }
148
+ }
149
+ }
150
+
151
+ // ── Command ────────────────────────────────────────────────────────────────
152
+
153
+ export const listCommand = (
154
+ options: ListOptions,
155
+ ): Effect.Effect<void, ApiError | ConfigError, ConfigServiceImpl | GerritApiService> =>
156
+ Effect.gen(function* () {
157
+ const configService = yield* ConfigService
158
+ const credentials = yield* configService.getCredentials
159
+ const gerritApi = yield* GerritApiService
160
+
161
+ const status = options.status ?? 'open'
162
+ const limit = options.limit ?? 25
163
+ const user = credentials.username
164
+
165
+ const baseQuery = options.reviewer
166
+ ? `(reviewer:${user} OR cc:${user}) status:${status}`
167
+ : `owner:${user} status:${status}`
168
+ const query = options.filter ? `${baseQuery} ${options.filter}` : baseQuery
169
+
170
+ const changes = yield* gerritApi.listChanges(query)
171
+ const limited = changes.slice(0, limit)
172
+
173
+ if (options.json) {
174
+ console.log(
175
+ JSON.stringify(
176
+ {
177
+ status: 'success',
178
+ count: limited.length,
179
+ changes: limited.map((c) => ({
180
+ number: c._number,
181
+ subject: c.subject,
182
+ project: c.project,
183
+ branch: c.branch,
184
+ status: c.status,
185
+ change_id: c.change_id,
186
+ ...(c.updated ? { updated: c.updated } : {}),
187
+ ...(c.owner?.name ? { owner: c.owner.name } : {}),
188
+ ...(c.labels ? { labels: c.labels } : {}),
189
+ })),
190
+ },
191
+ null,
192
+ 2,
193
+ ),
194
+ )
195
+ return
196
+ }
197
+
198
+ if (options.xml) {
199
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
200
+ console.log(`<changes count="${limited.length}">`)
201
+ for (const c of limited) {
202
+ console.log(` <change>`)
203
+ console.log(` <number>${c._number}</number>`)
204
+ console.log(` <subject><![CDATA[${c.subject}]]></subject>`)
205
+ console.log(` <project>${c.project}</project>`)
206
+ console.log(` <branch>${c.branch}</branch>`)
207
+ console.log(` <status>${c.status}</status>`)
208
+ console.log(` <change_id>${c.change_id}</change_id>`)
209
+ if (c.updated) console.log(` <updated>${c.updated}</updated>`)
210
+ if (c.owner?.name) console.log(` <owner>${c.owner.name}</owner>`)
211
+ console.log(` </change>`)
212
+ }
213
+ console.log(`</changes>`)
214
+ return
215
+ }
216
+
217
+ if (limited.length === 0) {
218
+ console.log(
219
+ chalk.dim(options.reviewer ? 'No changes need your review.' : 'No changes found.'),
220
+ )
221
+ return
222
+ }
223
+
224
+ if (options.detailed) {
225
+ for (const [i, change] of limited.entries()) {
226
+ if (i > 0) console.log('')
227
+ renderDetailed(change)
228
+ }
229
+ return
230
+ }
231
+
232
+ const showOwner = options.reviewer === true
233
+ console.log('')
234
+ renderTableHeader(showOwner)
235
+ for (const change of limited) {
236
+ renderTableRow(change, showOwner)
237
+ }
238
+ console.log('')
239
+ })
@@ -5,6 +5,7 @@ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
5
5
 
6
6
  interface RebaseOptions {
7
7
  base?: string
8
+ allowConflicts?: boolean
8
9
  xml?: boolean
9
10
  json?: boolean
10
11
  }
@@ -29,7 +30,10 @@ export const rebaseCommand = (
29
30
  const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
30
31
 
31
32
  // Perform the rebase - this returns the rebased change info
32
- const change = yield* gerritApi.rebaseChange(resolvedChangeId, { base: options.base })
33
+ const change = yield* gerritApi.rebaseChange(resolvedChangeId, {
34
+ base: options.base,
35
+ allowConflicts: options.allowConflicts,
36
+ })
33
37
 
34
38
  if (options.json) {
35
39
  console.log(
@@ -0,0 +1,91 @@
1
+ import { Effect } from 'effect'
2
+ import chalk from 'chalk'
3
+ import { input } from '@inquirer/prompts'
4
+ import { type ApiError, GerritApiService, type GerritApiServiceImpl } from '@/api/gerrit'
5
+ import { type ConfigError, ConfigService, type ConfigServiceImpl } from '@/services/config'
6
+ import { getChangeIdFromHead, type GitError, type NoChangeIdError } from '@/utils/git-commit'
7
+
8
+ export const RETRIGGER_HELP_TEXT = `
9
+ Examples:
10
+ # Retrigger CI for the change in HEAD commit (auto-detected)
11
+ $ ger retrigger
12
+
13
+ # Retrigger CI for a specific change
14
+ $ ger retrigger 12345
15
+
16
+ Notes:
17
+ - The retrigger comment is saved in config (set during "ger setup" or prompted on first use)
18
+ - Auto-detection reads the Change-Id footer from HEAD commit`
19
+
20
+ export interface RetriggerOptions {
21
+ xml?: boolean
22
+ json?: boolean
23
+ }
24
+
25
+ export const retriggerCommand = (
26
+ changeId: string | undefined,
27
+ options: RetriggerOptions,
28
+ ): Effect.Effect<
29
+ void,
30
+ ApiError | ConfigError | GitError | NoChangeIdError | Error,
31
+ GerritApiServiceImpl | ConfigServiceImpl
32
+ > =>
33
+ Effect.gen(function* () {
34
+ // Resolve change ID — explicit arg or auto-detect from HEAD
35
+ const resolvedChangeId = changeId !== undefined ? changeId : yield* getChangeIdFromHead()
36
+
37
+ // Get retrigger comment from config
38
+ const configService = yield* ConfigService
39
+ let retriggerComment = yield* configService.getRetriggerComment
40
+
41
+ // If not configured, prompt and save
42
+ if (!retriggerComment) {
43
+ if (!options.xml && !options.json) {
44
+ console.log(chalk.yellow('No retrigger comment configured.'))
45
+ console.log(
46
+ chalk.dim('This comment will be posted to trigger CI. It will be saved to config.'),
47
+ )
48
+ }
49
+
50
+ const prompted = yield* Effect.tryPromise({
51
+ try: () =>
52
+ input({
53
+ message: 'CI retrigger comment',
54
+ }),
55
+ catch: (e) =>
56
+ new Error(e instanceof Error ? e.message : 'Failed to read retrigger comment'),
57
+ })
58
+
59
+ if (!prompted.trim()) {
60
+ return yield* Effect.fail(new Error('Retrigger comment cannot be empty'))
61
+ }
62
+
63
+ retriggerComment = prompted.trim()
64
+ yield* configService.saveRetriggerComment(retriggerComment)
65
+
66
+ if (!options.xml && !options.json) {
67
+ console.log(chalk.dim(' Retrigger comment saved to config'))
68
+ }
69
+ }
70
+
71
+ // Post the comment
72
+ const gerritApi = yield* GerritApiService
73
+
74
+ if (!options.xml && !options.json) {
75
+ console.log(chalk.bold(`Retriggering CI for change ${chalk.cyan(resolvedChangeId)}...`))
76
+ }
77
+
78
+ yield* gerritApi.postReview(resolvedChangeId, { message: retriggerComment })
79
+
80
+ if (options.json) {
81
+ console.log(JSON.stringify({ status: 'success', change_id: resolvedChangeId }, null, 2))
82
+ } else if (options.xml) {
83
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
84
+ console.log(`<retrigger>`)
85
+ console.log(` <status>success</status>`)
86
+ console.log(` <change_id><![CDATA[${resolvedChangeId}]]></change_id>`)
87
+ console.log(`</retrigger>`)
88
+ } else {
89
+ console.log(chalk.green(' ✓ CI retrigger comment posted'))
90
+ }
91
+ })