@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,284 @@
1
+ import { Console, Effect } from 'effect'
2
+ import chalk from 'chalk'
3
+ import type { ChangeInfo } from '@/schemas/gerrit'
4
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
5
+ import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
6
+ import { writeFileSync } from 'node:fs'
7
+ import { escapeXML } from '@/utils/shell-safety'
8
+
9
+ export interface AnalyzeOptions {
10
+ startDate?: string
11
+ endDate?: string
12
+ repo?: string
13
+ json?: boolean
14
+ xml?: boolean
15
+ markdown?: boolean
16
+ csv?: boolean
17
+ output?: string
18
+ }
19
+
20
+ export type AnalyzeErrors = ConfigError | ApiError | Error
21
+
22
+ interface RepoStats {
23
+ name: string
24
+ count: number
25
+ }
26
+
27
+ interface AuthorStats {
28
+ name: string
29
+ email: string
30
+ count: number
31
+ }
32
+
33
+ interface MonthStats {
34
+ month: string
35
+ count: number
36
+ }
37
+
38
+ interface AnalyticsResult {
39
+ totalMerged: number
40
+ dateRange: { start: string; end: string }
41
+ byRepo: readonly RepoStats[]
42
+ byAuthor: readonly AuthorStats[]
43
+ timeline: readonly MonthStats[]
44
+ }
45
+
46
+ const getDefaultStartDate = (): string => {
47
+ const d = new Date()
48
+ return `${d.getFullYear()}-01-01`
49
+ }
50
+
51
+ const getDefaultEndDate = (): string => new Date().toISOString().slice(0, 10)
52
+
53
+ const getChangeMonth = (change: ChangeInfo): string => {
54
+ const dateStr = change.submitted ?? change.updated ?? change.created ?? ''
55
+ return dateStr.slice(0, 7) // YYYY-MM
56
+ }
57
+
58
+ const aggregateByRepo = (changes: readonly ChangeInfo[]): readonly RepoStats[] => {
59
+ const counts = new Map<string, number>()
60
+ for (const c of changes) {
61
+ counts.set(c.project, (counts.get(c.project) ?? 0) + 1)
62
+ }
63
+ return [...counts.entries()]
64
+ .map(([name, count]) => ({ name, count }))
65
+ .sort((a, b) => b.count - a.count)
66
+ }
67
+
68
+ const aggregateByAuthor = (changes: readonly ChangeInfo[]): readonly AuthorStats[] => {
69
+ const counts = new Map<string, { name: string; email: string; count: number }>()
70
+ for (const c of changes) {
71
+ const owner = c.owner
72
+ if (!owner) continue
73
+ const key = owner.email ?? owner.name ?? String(owner._account_id)
74
+ const existing = counts.get(key)
75
+ if (existing) {
76
+ existing.count++
77
+ } else {
78
+ counts.set(key, {
79
+ name: owner.name ?? 'Unknown',
80
+ email: owner.email ?? '',
81
+ count: 1,
82
+ })
83
+ }
84
+ }
85
+ return [...counts.values()].sort((a, b) => b.count - a.count)
86
+ }
87
+
88
+ const aggregateByMonth = (changes: readonly ChangeInfo[]): readonly MonthStats[] => {
89
+ const counts = new Map<string, number>()
90
+ for (const c of changes) {
91
+ const month = getChangeMonth(c)
92
+ counts.set(month, (counts.get(month) ?? 0) + 1)
93
+ }
94
+ return [...counts.entries()]
95
+ .map(([month, count]) => ({ month, count }))
96
+ .sort((a, b) => a.month.localeCompare(b.month))
97
+ }
98
+
99
+ const BAR_WIDTH = 30
100
+
101
+ const renderBar = (count: number, max: number): string => {
102
+ const filled = max > 0 ? Math.round((count / max) * BAR_WIDTH) : 0
103
+ return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
104
+ }
105
+
106
+ const renderTerminal = (result: AnalyticsResult): string => {
107
+ const lines: string[] = []
108
+
109
+ lines.push('')
110
+ lines.push(chalk.bold.cyan(' Contribution Analytics'))
111
+ lines.push(chalk.dim(` ${result.dateRange.start} → ${result.dateRange.end}`))
112
+ lines.push('')
113
+ lines.push(chalk.bold(` Total merged: ${chalk.green(String(result.totalMerged))}`))
114
+ lines.push('')
115
+
116
+ // By Repo
117
+ lines.push(chalk.bold.yellow(' ── Changes by Repository ──'))
118
+ lines.push('')
119
+ const maxRepo = result.byRepo[0]?.count ?? 1
120
+ for (const r of result.byRepo.slice(0, 15)) {
121
+ const bar = renderBar(r.count, maxRepo)
122
+ const name = r.name.length > 35 ? `...${r.name.slice(-32)}` : r.name
123
+ lines.push(
124
+ ` ${chalk.cyan(name.padEnd(35))} ${chalk.green(bar)} ${chalk.bold(String(r.count))}`,
125
+ )
126
+ }
127
+ lines.push('')
128
+
129
+ // By Author
130
+ lines.push(chalk.bold.yellow(' ── Changes by Author ──'))
131
+ lines.push('')
132
+ const maxAuthor = result.byAuthor[0]?.count ?? 1
133
+ for (const a of result.byAuthor.slice(0, 10)) {
134
+ const bar = renderBar(a.count, maxAuthor)
135
+ const label = a.name.length > 25 ? `${a.name.slice(0, 22)}...` : a.name
136
+ lines.push(
137
+ ` ${chalk.magenta(label.padEnd(25))} ${chalk.green(bar)} ${chalk.bold(String(a.count))}`,
138
+ )
139
+ }
140
+ lines.push('')
141
+
142
+ // Timeline
143
+ lines.push(chalk.bold.yellow(' ── Timeline ──'))
144
+ lines.push('')
145
+ const maxMonth = Math.max(...result.timeline.map((m) => m.count), 1)
146
+ for (const m of result.timeline) {
147
+ const bar = renderBar(m.count, maxMonth)
148
+ lines.push(` ${chalk.blue(m.month)} ${chalk.green(bar)} ${chalk.bold(String(m.count))}`)
149
+ }
150
+ lines.push('')
151
+
152
+ return lines.join('\n')
153
+ }
154
+
155
+ const renderMarkdown = (result: AnalyticsResult): string => {
156
+ const lines: string[] = []
157
+ lines.push(`# Contribution Analytics`)
158
+ lines.push(``)
159
+ lines.push(`**Date range:** ${result.dateRange.start} → ${result.dateRange.end}`)
160
+ lines.push(`**Total merged:** ${result.totalMerged}`)
161
+ lines.push(``)
162
+
163
+ lines.push(`## Changes by Repository`)
164
+ lines.push(``)
165
+ lines.push(`| Repository | Count |`)
166
+ lines.push(`|---|---|`)
167
+ for (const r of result.byRepo) lines.push(`| ${r.name} | ${r.count} |`)
168
+ lines.push(``)
169
+
170
+ lines.push(`## Changes by Author`)
171
+ lines.push(``)
172
+ lines.push(`| Author | Email | Count |`)
173
+ lines.push(`|---|---|---|`)
174
+ for (const a of result.byAuthor) lines.push(`| ${a.name} | ${a.email} | ${a.count} |`)
175
+ lines.push(``)
176
+
177
+ lines.push(`## Timeline`)
178
+ lines.push(``)
179
+ lines.push(`| Month | Count |`)
180
+ lines.push(`|---|---|`)
181
+ for (const m of result.timeline) lines.push(`| ${m.month} | ${m.count} |`)
182
+ lines.push(``)
183
+
184
+ return lines.join('\n')
185
+ }
186
+
187
+ const csvField = (v: string): string => `"${v.replace(/"/g, '""')}"`
188
+
189
+ const renderCsv = (result: AnalyticsResult): string => {
190
+ const lines: string[] = []
191
+ lines.push('section,key,count')
192
+ for (const r of result.byRepo) lines.push(`repo,${csvField(r.name)},${r.count}`)
193
+ for (const a of result.byAuthor)
194
+ lines.push(`author,${csvField(`${a.name} <${a.email}>`)},${a.count}`)
195
+ for (const m of result.timeline) lines.push(`timeline,${csvField(m.month)},${m.count}`)
196
+ return lines.join('\n')
197
+ }
198
+
199
+ const renderXml = (result: AnalyticsResult): string => {
200
+ const lines: string[] = []
201
+ lines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
202
+ lines.push(`<analytics>`)
203
+ lines.push(` <total_merged>${result.totalMerged}</total_merged>`)
204
+ lines.push(` <date_range start="${result.dateRange.start}" end="${result.dateRange.end}"/>`)
205
+
206
+ lines.push(` <by_repo>`)
207
+ for (const r of result.byRepo) {
208
+ lines.push(` <repo name="${escapeXML(r.name)}" count="${r.count}"/>`)
209
+ }
210
+ lines.push(` </by_repo>`)
211
+
212
+ lines.push(` <by_author>`)
213
+ for (const a of result.byAuthor) {
214
+ const escaped = a.name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
215
+ lines.push(` <author name="${escaped}" email="${a.email}" count="${a.count}"/>`)
216
+ }
217
+ lines.push(` </by_author>`)
218
+
219
+ lines.push(` <timeline>`)
220
+ for (const m of result.timeline) {
221
+ lines.push(` <month value="${m.month}" count="${m.count}"/>`)
222
+ }
223
+ lines.push(` </timeline>`)
224
+
225
+ lines.push(`</analytics>`)
226
+ return lines.join('\n')
227
+ }
228
+
229
+ export const analyzeCommand = (
230
+ options: AnalyzeOptions,
231
+ ): Effect.Effect<void, AnalyzeErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
232
+ Effect.gen(function* () {
233
+ const startDate = options.startDate ?? getDefaultStartDate()
234
+ const endDate = options.endDate ?? getDefaultEndDate()
235
+
236
+ const _configService = yield* ConfigService
237
+ const apiService = yield* GerritApiService
238
+
239
+ const isMachineReadable =
240
+ options.json || options.xml || options.markdown || options.csv || options.output
241
+ if (!isMachineReadable) {
242
+ yield* Console.log(chalk.dim(`Fetching merged changes from ${startDate} to ${endDate}...`))
243
+ }
244
+
245
+ const changes = yield* apiService.fetchMergedChanges({
246
+ after: startDate,
247
+ before: endDate,
248
+ repo: options.repo,
249
+ })
250
+
251
+ const result: AnalyticsResult = {
252
+ totalMerged: changes.length,
253
+ dateRange: { start: startDate, end: endDate },
254
+ byRepo: aggregateByRepo(changes),
255
+ byAuthor: aggregateByAuthor(changes),
256
+ timeline: aggregateByMonth(changes),
257
+ }
258
+
259
+ let output: string
260
+
261
+ if (options.json) {
262
+ output = JSON.stringify(result, null, 2)
263
+ } else if (options.xml) {
264
+ output = renderXml(result)
265
+ } else if (options.markdown) {
266
+ output = renderMarkdown(result)
267
+ } else if (options.csv) {
268
+ output = renderCsv(result)
269
+ } else {
270
+ output = renderTerminal(result)
271
+ }
272
+
273
+ if (options.output) {
274
+ const outputPath = options.output
275
+ yield* Effect.try({
276
+ try: () => writeFileSync(outputPath, output, 'utf8'),
277
+ catch: (e) =>
278
+ new Error(`Failed to write output file: ${e instanceof Error ? e.message : String(e)}`),
279
+ })
280
+ yield* Console.log(chalk.green(`✓ Output written to ${options.output}`))
281
+ } else {
282
+ yield* Console.log(output)
283
+ }
284
+ })
@@ -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
+ })