@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.
- package/docs/prd/commands.md +59 -1
- package/package.json +1 -1
- package/src/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +68 -102
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/files.ts +86 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/reviewers.ts +95 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +96 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/schemas/gerrit.ts +2 -0
- package/src/schemas/reviewer.ts +16 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/files.test.ts +223 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/reviewers.test.ts +259 -0
- package/tests/tree.test.ts +517 -0
- 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, {
|
|
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 ||
|
|
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
|