@aaronshaf/ger 3.0.1 → 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.
Files changed (36) hide show
  1. package/docs/prd/commands.md +59 -1
  2. package/package.json +1 -1
  3. package/src/api/gerrit-types.ts +121 -0
  4. package/src/api/gerrit.ts +68 -102
  5. package/src/cli/commands/analyze.ts +284 -0
  6. package/src/cli/commands/cherry.ts +268 -0
  7. package/src/cli/commands/failures.ts +74 -0
  8. package/src/cli/commands/files.ts +86 -0
  9. package/src/cli/commands/list.ts +239 -0
  10. package/src/cli/commands/rebase.ts +5 -1
  11. package/src/cli/commands/retrigger.ts +91 -0
  12. package/src/cli/commands/reviewers.ts +95 -0
  13. package/src/cli/commands/setup.ts +19 -6
  14. package/src/cli/commands/tree-cleanup.ts +139 -0
  15. package/src/cli/commands/tree-rebase.ts +168 -0
  16. package/src/cli/commands/tree-setup.ts +202 -0
  17. package/src/cli/commands/trees.ts +107 -0
  18. package/src/cli/commands/update.ts +73 -0
  19. package/src/cli/register-analytics-commands.ts +90 -0
  20. package/src/cli/register-commands.ts +96 -40
  21. package/src/cli/register-list-commands.ts +105 -0
  22. package/src/cli/register-tree-commands.ts +128 -0
  23. package/src/schemas/config.ts +3 -0
  24. package/src/schemas/gerrit.ts +2 -0
  25. package/src/schemas/reviewer.ts +16 -0
  26. package/src/services/config.ts +15 -0
  27. package/tests/analyze.test.ts +197 -0
  28. package/tests/cherry.test.ts +208 -0
  29. package/tests/failures.test.ts +212 -0
  30. package/tests/files.test.ts +223 -0
  31. package/tests/helpers/config-mock.ts +4 -0
  32. package/tests/list.test.ts +220 -0
  33. package/tests/retrigger.test.ts +159 -0
  34. package/tests/reviewers.test.ts +259 -0
  35. package/tests/tree.test.ts +517 -0
  36. package/tests/update.test.ts +86 -0
@@ -0,0 +1,86 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
4
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
5
+
6
+ const MAGIC_FILES = new Set(['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'])
7
+
8
+ interface FilesOptions {
9
+ xml?: boolean
10
+ json?: boolean
11
+ }
12
+
13
+ export const filesCommand = (
14
+ changeId?: string,
15
+ options: FilesOptions = {},
16
+ ): Effect.Effect<void, never, GerritApiService> =>
17
+ Effect.gen(function* () {
18
+ const gerritApi = yield* GerritApiService
19
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
20
+
21
+ const filesRecord = yield* gerritApi.getFiles(resolvedChangeId)
22
+ const files = Object.entries(filesRecord)
23
+ .filter(([path]) => !MAGIC_FILES.has(path))
24
+ .map(([path, info]) => ({
25
+ path,
26
+ status: info.status ?? 'M',
27
+ lines_inserted: info.lines_inserted ?? 0,
28
+ lines_deleted: info.lines_deleted ?? 0,
29
+ }))
30
+
31
+ if (options.json) {
32
+ console.log(
33
+ JSON.stringify(
34
+ {
35
+ status: 'success',
36
+ change_id: resolvedChangeId,
37
+ files,
38
+ },
39
+ null,
40
+ 2,
41
+ ),
42
+ )
43
+ } else if (options.xml) {
44
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
45
+ console.log(`<files_result>`)
46
+ console.log(` <status>success</status>`)
47
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
48
+ console.log(` <files>`)
49
+ for (const file of files) {
50
+ console.log(` <file>`)
51
+ console.log(` <path><![CDATA[${sanitizeCDATA(file.path)}]]></path>`)
52
+ console.log(` <status>${escapeXML(file.status)}</status>`)
53
+ console.log(` <lines_inserted>${file.lines_inserted}</lines_inserted>`)
54
+ console.log(` <lines_deleted>${file.lines_deleted}</lines_deleted>`)
55
+ console.log(` </file>`)
56
+ }
57
+ console.log(` </files>`)
58
+ console.log(`</files_result>`)
59
+ } else {
60
+ for (const file of files) {
61
+ console.log(`${file.status} ${file.path}`)
62
+ }
63
+ }
64
+ }).pipe(
65
+ Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
66
+ Effect.sync(() => {
67
+ const errorMessage =
68
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
69
+ ? error.message
70
+ : String(error)
71
+
72
+ if (options.json) {
73
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
74
+ } else if (options.xml) {
75
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
76
+ console.log(`<files_result>`)
77
+ console.log(` <status>error</status>`)
78
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
79
+ console.log(`</files_result>`)
80
+ } else {
81
+ console.error(`✗ Error: ${errorMessage}`)
82
+ }
83
+ process.exit(1)
84
+ }),
85
+ ),
86
+ )
@@ -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
+ })
@@ -0,0 +1,95 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
4
+ import { sanitizeCDATA } from '@/utils/shell-safety'
5
+ import type { ReviewerListItem } from '@/schemas/reviewer'
6
+
7
+ interface ReviewersOptions {
8
+ xml?: boolean
9
+ json?: boolean
10
+ }
11
+
12
+ function formatReviewer(r: ReviewerListItem): string {
13
+ const name =
14
+ r.name ?? r.username ?? (r._account_id !== undefined ? `#${r._account_id}` : undefined)
15
+ if (name !== undefined) return r.email ? `${name} <${r.email}>` : name
16
+ return r.email ?? 'unknown'
17
+ }
18
+
19
+ export const reviewersCommand = (
20
+ changeId?: string,
21
+ options: ReviewersOptions = {},
22
+ ): Effect.Effect<void, never, GerritApiService> =>
23
+ Effect.gen(function* () {
24
+ const gerritApi = yield* GerritApiService
25
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
26
+
27
+ const reviewers = yield* gerritApi.getReviewers(resolvedChangeId)
28
+
29
+ if (options.json) {
30
+ console.log(
31
+ JSON.stringify(
32
+ {
33
+ status: 'success',
34
+ change_id: resolvedChangeId,
35
+ reviewers: reviewers.map((r) => ({
36
+ ...(r._account_id !== undefined ? { account_id: r._account_id } : {}),
37
+ name: r.name,
38
+ email: r.email,
39
+ username: r.username,
40
+ })),
41
+ },
42
+ null,
43
+ 2,
44
+ ),
45
+ )
46
+ } else if (options.xml) {
47
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
48
+ console.log(`<reviewers_result>`)
49
+ console.log(` <status>success</status>`)
50
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
51
+ console.log(` <reviewers>`)
52
+ for (const r of reviewers) {
53
+ console.log(` <reviewer>`)
54
+ if (r._account_id !== undefined)
55
+ console.log(` <account_id>${r._account_id}</account_id>`)
56
+ if (r.name) console.log(` <name><![CDATA[${sanitizeCDATA(r.name)}]]></name>`)
57
+ if (r.email) console.log(` <email><![CDATA[${sanitizeCDATA(r.email)}]]></email>`)
58
+ if (r.username)
59
+ console.log(` <username><![CDATA[${sanitizeCDATA(r.username)}]]></username>`)
60
+ console.log(` </reviewer>`)
61
+ }
62
+ console.log(` </reviewers>`)
63
+ console.log(`</reviewers_result>`)
64
+ } else {
65
+ if (reviewers.length === 0) {
66
+ console.log('No reviewers')
67
+ } else {
68
+ for (const r of reviewers) {
69
+ console.log(formatReviewer(r))
70
+ }
71
+ }
72
+ }
73
+ }).pipe(
74
+ Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
75
+ Effect.sync(() => {
76
+ const errorMessage =
77
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
78
+ ? error.message
79
+ : String(error)
80
+
81
+ if (options.json) {
82
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
83
+ } else if (options.xml) {
84
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
85
+ console.log(`<reviewers_result>`)
86
+ console.log(` <status>error</status>`)
87
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
88
+ console.log(`</reviewers_result>`)
89
+ } else {
90
+ console.error(`✗ Error: ${errorMessage}`)
91
+ }
92
+ process.exit(1)
93
+ }),
94
+ ),
95
+ )
@@ -193,11 +193,8 @@ const setupEffect = (configService: ConfigServiceImpl) =>
193
193
  console.log(chalk.dim(`Detected AI tools: ${availableTools.join(', ')}`))
194
194
  }
195
195
 
196
- // Get default suggestion
197
- const defaultCommand =
198
- existingConfig?.aiTool ||
199
- (availableTools.includes('claude') ? 'claude' : availableTools[0]) ||
200
- ''
196
+ // Get default suggestion — no default to claude
197
+ const defaultCommand = existingConfig?.aiTool || (availableTools[0] ?? '')
201
198
 
202
199
  // AI tool command with smart default
203
200
  const aiToolCommand = await input({
@@ -205,7 +202,20 @@ const setupEffect = (configService: ConfigServiceImpl) =>
205
202
  availableTools.length > 0
206
203
  ? 'AI tool command (detected from system)'
207
204
  : 'AI tool command (e.g., claude, llm, opencode, gemini)',
208
- default: defaultCommand || 'claude',
205
+ default: defaultCommand || undefined,
206
+ })
207
+
208
+ console.log('')
209
+ console.log(chalk.yellow('Optional: CI Retrigger'))
210
+ console.log(
211
+ chalk.dim(
212
+ 'Comment to post when triggering a CI build (e.g. a magic trigger string your CI watches for)',
213
+ ),
214
+ )
215
+
216
+ const retriggerComment = await input({
217
+ message: 'CI retrigger comment (leave blank to skip)',
218
+ default: existingConfig?.retriggerComment ?? undefined,
209
219
  })
210
220
 
211
221
  // Build flat config
@@ -217,6 +227,9 @@ const setupEffect = (configService: ConfigServiceImpl) =>
217
227
  aiTool: aiToolCommand,
218
228
  }),
219
229
  aiAutoDetect: !aiToolCommand,
230
+ ...(retriggerComment.trim() && {
231
+ retriggerComment: retriggerComment.trim(),
232
+ }),
220
233
  }
221
234
 
222
235
  // Validate config using Schema instead of type assertion