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