@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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
})
|