@aaronshaf/ger 0.1.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/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { select } from '@inquirer/prompts'
|
|
3
|
+
import { exec } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
6
|
+
import { type ConfigError, ConfigService } from '@/services/config'
|
|
7
|
+
import { colors } from '@/utils/formatters'
|
|
8
|
+
import { getStatusIndicators } from '@/utils/status-indicators'
|
|
9
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
10
|
+
import { sanitizeUrlSync, getOpenCommand } from '@/utils/shell-safety'
|
|
11
|
+
|
|
12
|
+
const execAsync = promisify(exec)
|
|
13
|
+
|
|
14
|
+
interface IncomingOptions {
|
|
15
|
+
xml?: boolean
|
|
16
|
+
interactive?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Group changes by project for better organization
|
|
20
|
+
const groupChangesByProject = (changes: readonly ChangeInfo[]) => {
|
|
21
|
+
const grouped = new Map<string, ChangeInfo[]>()
|
|
22
|
+
|
|
23
|
+
for (const change of changes) {
|
|
24
|
+
const project = change.project
|
|
25
|
+
if (!grouped.has(project)) {
|
|
26
|
+
grouped.set(project, [])
|
|
27
|
+
}
|
|
28
|
+
grouped.get(project)!.push(change)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sort projects alphabetically and changes by updated date
|
|
32
|
+
return Array.from(grouped.entries())
|
|
33
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
34
|
+
.map(([project, projectChanges]) => ({
|
|
35
|
+
project,
|
|
36
|
+
changes: projectChanges.sort((a, b) => {
|
|
37
|
+
const dateA = a.updated ? new Date(a.updated).getTime() : 0
|
|
38
|
+
const dateB = b.updated ? new Date(b.updated).getTime() : 0
|
|
39
|
+
return dateB - dateA
|
|
40
|
+
}),
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Format change for display in inquirer
|
|
45
|
+
const formatChangeChoice = (change: ChangeInfo) => {
|
|
46
|
+
const indicators = getStatusIndicators(change)
|
|
47
|
+
const statusPart = indicators ? `${indicators} ` : ''
|
|
48
|
+
const subject =
|
|
49
|
+
change.subject.length > 60 ? `${change.subject.substring(0, 57)}...` : change.subject
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
name: `${statusPart}${subject} (${change._number})`,
|
|
53
|
+
value: change,
|
|
54
|
+
description: `By ${change.owner?.name || 'Unknown'} • ${change.status}`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Open change in browser
|
|
59
|
+
const openInBrowser = async (gerritHost: string, changeNumber: number) => {
|
|
60
|
+
const url = `${gerritHost}/c/${changeNumber}`
|
|
61
|
+
const sanitizedUrl = sanitizeUrlSync(url)
|
|
62
|
+
|
|
63
|
+
if (!sanitizedUrl) {
|
|
64
|
+
console.error(`${colors.red}✗ Invalid URL: ${url}${colors.reset}`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const openCmd = getOpenCommand()
|
|
69
|
+
try {
|
|
70
|
+
await execAsync(`${openCmd} "${sanitizedUrl}"`)
|
|
71
|
+
console.log(`${colors.green}✓ Opened ${changeNumber} in browser${colors.reset}`)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`${colors.red}✗ Failed to open browser: ${error}${colors.reset}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const incomingCommand = (
|
|
78
|
+
options: IncomingOptions,
|
|
79
|
+
): Effect.Effect<void, ApiError | ConfigError, GerritApiService | ConfigService> =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const gerritApi = yield* GerritApiService
|
|
82
|
+
|
|
83
|
+
// Query for changes where user is a reviewer but not the owner
|
|
84
|
+
const changes = yield* gerritApi.listChanges(
|
|
85
|
+
'is:open -owner:self -is:wip -is:ignored reviewer:self',
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if (options.interactive) {
|
|
89
|
+
if (changes.length === 0) {
|
|
90
|
+
console.log(`${colors.yellow}No incoming reviews found${colors.reset}`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Get Gerrit host for opening changes in browser
|
|
95
|
+
const configService = yield* ConfigService
|
|
96
|
+
const credentials = yield* configService.getCredentials
|
|
97
|
+
|
|
98
|
+
// Group changes by project
|
|
99
|
+
const groupedChanges = groupChangesByProject(changes)
|
|
100
|
+
|
|
101
|
+
// Create choices for inquirer with project sections
|
|
102
|
+
const choices: Array<{ name: string; value: ChangeInfo | string }> = []
|
|
103
|
+
|
|
104
|
+
for (const { project, changes: projectChanges } of groupedChanges) {
|
|
105
|
+
// Add project header as separator
|
|
106
|
+
choices.push({
|
|
107
|
+
name: `\n${colors.blue}━━━ ${project} ━━━${colors.reset}`,
|
|
108
|
+
value: 'separator',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Add changes for this project
|
|
112
|
+
for (const change of projectChanges) {
|
|
113
|
+
const formatted = formatChangeChoice(change)
|
|
114
|
+
choices.push({
|
|
115
|
+
name: formatted.name,
|
|
116
|
+
value: change,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add exit option
|
|
122
|
+
choices.push({
|
|
123
|
+
name: `\n${colors.gray}Exit${colors.reset}`,
|
|
124
|
+
value: 'exit',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Interactive selection loop
|
|
128
|
+
let continueSelecting = true
|
|
129
|
+
while (continueSelecting) {
|
|
130
|
+
const selected = yield* Effect.promise(async () => {
|
|
131
|
+
return await select({
|
|
132
|
+
message: 'Select a change to open in browser:',
|
|
133
|
+
choices: choices.filter((c) => c.value !== 'separator'),
|
|
134
|
+
pageSize: 15,
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if (selected === 'exit' || !selected) {
|
|
139
|
+
continueSelecting = false
|
|
140
|
+
} else if (typeof selected !== 'string') {
|
|
141
|
+
// Open the selected change
|
|
142
|
+
yield* Effect.promise(() => openInBrowser(credentials.host, selected._number))
|
|
143
|
+
|
|
144
|
+
// Ask if user wants to continue
|
|
145
|
+
const continueChoice = yield* Effect.promise(async () => {
|
|
146
|
+
return await select({
|
|
147
|
+
message: 'Continue?',
|
|
148
|
+
choices: [
|
|
149
|
+
{ name: 'Select another change', value: 'continue' },
|
|
150
|
+
{ name: 'Exit', value: 'exit' },
|
|
151
|
+
],
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if (continueChoice === 'exit') {
|
|
156
|
+
continueSelecting = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.xml) {
|
|
165
|
+
// XML output
|
|
166
|
+
const xmlOutput = [
|
|
167
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
168
|
+
'<incoming_reviews>',
|
|
169
|
+
` <count>${changes.length}</count>`,
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if (changes.length > 0) {
|
|
173
|
+
xmlOutput.push(' <changes>')
|
|
174
|
+
|
|
175
|
+
// Group by project for XML output too
|
|
176
|
+
const groupedChanges = groupChangesByProject(changes)
|
|
177
|
+
|
|
178
|
+
for (const { project, changes: projectChanges } of groupedChanges) {
|
|
179
|
+
xmlOutput.push(` <project name="${project}">`)
|
|
180
|
+
for (const change of projectChanges) {
|
|
181
|
+
xmlOutput.push(' <change>')
|
|
182
|
+
xmlOutput.push(` <number>${change._number}</number>`)
|
|
183
|
+
xmlOutput.push(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
184
|
+
xmlOutput.push(` <status>${change.status}</status>`)
|
|
185
|
+
xmlOutput.push(` <owner>${change.owner?.name || 'Unknown'}</owner>`)
|
|
186
|
+
xmlOutput.push(` <updated>${change.updated}</updated>`)
|
|
187
|
+
xmlOutput.push(' </change>')
|
|
188
|
+
}
|
|
189
|
+
xmlOutput.push(' </project>')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
xmlOutput.push(' </changes>')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
xmlOutput.push('</incoming_reviews>')
|
|
196
|
+
console.log(xmlOutput.join('\n'))
|
|
197
|
+
} else {
|
|
198
|
+
// Pretty output (default)
|
|
199
|
+
if (changes.length === 0) {
|
|
200
|
+
console.log(`${colors.green}✓ No incoming reviews${colors.reset}`)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`${colors.blue}Incoming Reviews (${changes.length})${colors.reset}\n`)
|
|
205
|
+
|
|
206
|
+
// Group by project for display
|
|
207
|
+
const groupedChanges = groupChangesByProject(changes)
|
|
208
|
+
|
|
209
|
+
for (const { project, changes: projectChanges } of groupedChanges) {
|
|
210
|
+
console.log(`${colors.gray}${project}${colors.reset}`)
|
|
211
|
+
|
|
212
|
+
for (const change of projectChanges) {
|
|
213
|
+
const indicators = getStatusIndicators(change)
|
|
214
|
+
const statusPart = indicators ? `${indicators} ` : ''
|
|
215
|
+
|
|
216
|
+
console.log(
|
|
217
|
+
` ${statusPart}${colors.yellow}#${change._number}${colors.reset} ${change.subject}`,
|
|
218
|
+
)
|
|
219
|
+
console.log(
|
|
220
|
+
` ${colors.gray}by ${change.owner?.name || 'Unknown'} • ${change.status}${colors.reset}`,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
console.log() // Empty line between projects
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as readline from 'node:readline/promises'
|
|
5
|
+
import { Effect } from 'effect'
|
|
6
|
+
import type { GerritCredentials } from '@/schemas/gerrit'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILE = path.join(os.homedir(), '.ger', 'auth.json')
|
|
10
|
+
|
|
11
|
+
const obscureToken = (token: string): string => {
|
|
12
|
+
if (token.length <= 8) return '****'
|
|
13
|
+
return `${token.substring(0, 4)}****${token.substring(token.length - 4)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hidden password input using manual stdin manipulation
|
|
17
|
+
const readPassword = async (prompt: string, fallbackPrompt?: string): Promise<string> => {
|
|
18
|
+
const stdin = process.stdin
|
|
19
|
+
const stdout = process.stdout
|
|
20
|
+
|
|
21
|
+
// Check if we can use raw mode (TTY environment)
|
|
22
|
+
if (!stdin.isTTY || !stdin.setRawMode) {
|
|
23
|
+
// Fallback to regular readline with warning
|
|
24
|
+
if (fallbackPrompt) {
|
|
25
|
+
console.log('⚠️ Note: Password will be visible while typing (non-TTY environment)')
|
|
26
|
+
}
|
|
27
|
+
const rl = readline.createInterface({
|
|
28
|
+
input: stdin,
|
|
29
|
+
output: stdout,
|
|
30
|
+
})
|
|
31
|
+
const answer = await rl.question(fallbackPrompt || prompt)
|
|
32
|
+
rl.close()
|
|
33
|
+
return answer
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
stdout.write(prompt)
|
|
38
|
+
|
|
39
|
+
stdin.setRawMode(true)
|
|
40
|
+
stdin.resume()
|
|
41
|
+
stdin.setEncoding('utf8')
|
|
42
|
+
|
|
43
|
+
let password = ''
|
|
44
|
+
|
|
45
|
+
const onData = (char: string) => {
|
|
46
|
+
const code = char.charCodeAt(0)
|
|
47
|
+
|
|
48
|
+
if (code === 3) {
|
|
49
|
+
// Ctrl+C
|
|
50
|
+
stdout.write('\n')
|
|
51
|
+
process.exit(0)
|
|
52
|
+
} else if (code === 13 || code === 10) {
|
|
53
|
+
// Enter
|
|
54
|
+
stdin.setRawMode(false)
|
|
55
|
+
stdin.pause()
|
|
56
|
+
stdin.removeListener('data', onData)
|
|
57
|
+
stdout.write('\n')
|
|
58
|
+
resolve(password)
|
|
59
|
+
} else if (code === 127 || code === 8) {
|
|
60
|
+
// Backspace
|
|
61
|
+
if (password.length > 0) {
|
|
62
|
+
password = password.slice(0, -1)
|
|
63
|
+
stdout.write('\b \b') // Move back, write space, move back again
|
|
64
|
+
}
|
|
65
|
+
} else if (code >= 32 && code <= 126) {
|
|
66
|
+
// Printable characters
|
|
67
|
+
password += char
|
|
68
|
+
stdout.write('*')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stdin.on('data', onData)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const readExistingConfig = (): GerritCredentials | null => {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
79
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf8')
|
|
80
|
+
return JSON.parse(content)
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore errors
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const initCommand = (): Effect.Effect<void, Error, ConfigService> =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const configService = yield* ConfigService
|
|
91
|
+
|
|
92
|
+
const rl = readline.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: process.stdout,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
console.log('Gerrit CLI Setup')
|
|
98
|
+
console.log('================')
|
|
99
|
+
console.log('')
|
|
100
|
+
|
|
101
|
+
// Load existing config if it exists
|
|
102
|
+
const existing = readExistingConfig()
|
|
103
|
+
|
|
104
|
+
if (existing) {
|
|
105
|
+
console.log('Found existing configuration:')
|
|
106
|
+
console.log(` Host: ${existing.host}`)
|
|
107
|
+
console.log(` Username: ${existing.username}`)
|
|
108
|
+
console.log(` Password: ${obscureToken(existing.password)}`)
|
|
109
|
+
console.log('')
|
|
110
|
+
console.log('Press Enter to keep existing values, or type new ones.')
|
|
111
|
+
console.log('')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Prompt for host
|
|
115
|
+
const hostPrompt = existing?.host
|
|
116
|
+
? `Gerrit host [${existing.host}]: `
|
|
117
|
+
: 'Gerrit host (e.g., https://gerrit.example.com): '
|
|
118
|
+
const host = yield* Effect.tryPromise(() => rl.question(hostPrompt)).pipe(
|
|
119
|
+
Effect.map((answer) => answer || existing?.host),
|
|
120
|
+
Effect.flatMap((value) =>
|
|
121
|
+
value ? Effect.succeed(value) : Effect.fail(new Error('Host is required')),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Prompt for username
|
|
126
|
+
const usernamePrompt = existing?.username ? `Username [${existing.username}]: ` : 'Username: '
|
|
127
|
+
const username = yield* Effect.tryPromise(() => rl.question(usernamePrompt)).pipe(
|
|
128
|
+
Effect.map((answer) => answer || existing?.username),
|
|
129
|
+
Effect.flatMap((value) =>
|
|
130
|
+
value ? Effect.succeed(value) : Effect.fail(new Error('Username is required')),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Close readline interface before password prompt (we'll use raw mode)
|
|
135
|
+
rl.close()
|
|
136
|
+
|
|
137
|
+
// Prompt for password (with hidden input)
|
|
138
|
+
const passwordPrompt = existing?.password
|
|
139
|
+
? `HTTP Password [${obscureToken(existing.password)}]: `
|
|
140
|
+
: 'HTTP Password (from Gerrit Settings → HTTP Password): '
|
|
141
|
+
|
|
142
|
+
const password = yield* Effect.tryPromise(() => readPassword(passwordPrompt)).pipe(
|
|
143
|
+
Effect.map((answer) => answer || existing?.password),
|
|
144
|
+
Effect.flatMap((value) =>
|
|
145
|
+
value ? Effect.succeed(value) : Effect.fail(new Error('Password is required')),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const credentials: GerritCredentials = {
|
|
150
|
+
host: host.replace(/\/$/, ''), // Remove trailing slash if present
|
|
151
|
+
username,
|
|
152
|
+
password,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
yield* configService.saveCredentials(credentials)
|
|
156
|
+
|
|
157
|
+
console.log('')
|
|
158
|
+
console.log('✓ Credentials saved to ~/.ger/auth.json')
|
|
159
|
+
console.log('')
|
|
160
|
+
console.log('You can now use commands like:')
|
|
161
|
+
console.log(' ger status')
|
|
162
|
+
console.log(' ger mine --pretty')
|
|
163
|
+
console.log(' ger workspace <change-id>')
|
|
164
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { colors } from '@/utils/formatters'
|
|
5
|
+
|
|
6
|
+
interface MineOptions {
|
|
7
|
+
xml?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ANSI color codes
|
|
11
|
+
|
|
12
|
+
export const mineCommand = (
|
|
13
|
+
options: MineOptions,
|
|
14
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const gerritApi = yield* GerritApiService
|
|
17
|
+
|
|
18
|
+
const changes = yield* gerritApi.listChanges('owner:self status:open')
|
|
19
|
+
|
|
20
|
+
if (options.xml) {
|
|
21
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
22
|
+
console.log(`<changes count="${changes.length}">`)
|
|
23
|
+
|
|
24
|
+
for (const change of changes) {
|
|
25
|
+
console.log(` <change>`)
|
|
26
|
+
console.log(` <number>${change._number}</number>`)
|
|
27
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
28
|
+
console.log(` <project>${change.project}</project>`)
|
|
29
|
+
console.log(` <branch>${change.branch}</branch>`)
|
|
30
|
+
console.log(` <status>${change.status}</status>`)
|
|
31
|
+
console.log(` <change_id>${change.change_id}</change_id>`)
|
|
32
|
+
if (change.updated) {
|
|
33
|
+
console.log(` <updated>${change.updated}</updated>`)
|
|
34
|
+
}
|
|
35
|
+
if (change.owner?.name) {
|
|
36
|
+
console.log(` <owner>${change.owner.name}</owner>`)
|
|
37
|
+
}
|
|
38
|
+
console.log(` </change>`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`</changes>`)
|
|
42
|
+
} else {
|
|
43
|
+
// Pretty output by default
|
|
44
|
+
if (changes.length === 0) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Group changes by project
|
|
49
|
+
const changesByProject = changes.reduce(
|
|
50
|
+
(acc, change) => {
|
|
51
|
+
if (!acc[change.project]) {
|
|
52
|
+
acc[change.project] = []
|
|
53
|
+
}
|
|
54
|
+
acc[change.project] = [...acc[change.project], change]
|
|
55
|
+
return acc
|
|
56
|
+
},
|
|
57
|
+
{} as Record<string, ChangeInfo[]>,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Sort projects alphabetically
|
|
61
|
+
const sortedProjects = Object.keys(changesByProject).sort()
|
|
62
|
+
|
|
63
|
+
for (const [index, project] of sortedProjects.entries()) {
|
|
64
|
+
if (index > 0) {
|
|
65
|
+
console.log('') // Add blank line between projects
|
|
66
|
+
}
|
|
67
|
+
console.log(`${colors.blue}${project}${colors.reset}`)
|
|
68
|
+
|
|
69
|
+
const projectChanges = changesByProject[project]
|
|
70
|
+
for (const change of projectChanges) {
|
|
71
|
+
// Build status indicators
|
|
72
|
+
const indicators: string[] = []
|
|
73
|
+
const indicatorChars: string[] = [] // Track visual characters for padding
|
|
74
|
+
|
|
75
|
+
if (change.labels?.['Code-Review']) {
|
|
76
|
+
const cr = change.labels['Code-Review']
|
|
77
|
+
if (cr.approved || cr.value === 2) {
|
|
78
|
+
indicators.push(`${colors.green}✓${colors.reset}`)
|
|
79
|
+
indicatorChars.push('✓')
|
|
80
|
+
} else if (cr.rejected || cr.value === -2) {
|
|
81
|
+
indicators.push(`${colors.red}✗${colors.reset}`)
|
|
82
|
+
indicatorChars.push('✗')
|
|
83
|
+
} else if (cr.recommended || cr.value === 1) {
|
|
84
|
+
indicators.push(`${colors.cyan}↑${colors.reset}`)
|
|
85
|
+
indicatorChars.push('↑')
|
|
86
|
+
} else if (cr.disliked || cr.value === -1) {
|
|
87
|
+
indicators.push(`${colors.yellow}↓${colors.reset}`)
|
|
88
|
+
indicatorChars.push('↓')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check for Verified label as well
|
|
93
|
+
if (change.labels?.['Verified']) {
|
|
94
|
+
const v = change.labels.Verified
|
|
95
|
+
if (v.approved || v.value === 1) {
|
|
96
|
+
if (!indicatorChars.includes('✓')) {
|
|
97
|
+
indicators.push(`${colors.green}✓${colors.reset}`)
|
|
98
|
+
indicatorChars.push('✓')
|
|
99
|
+
}
|
|
100
|
+
} else if (v.rejected || v.value === -1) {
|
|
101
|
+
indicators.push(`${colors.red}✗${colors.reset}`)
|
|
102
|
+
indicatorChars.push('✗')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Calculate padding based on visual characters, not color codes
|
|
107
|
+
const visualWidth = indicatorChars.join(' ').length
|
|
108
|
+
const padding = ' '.repeat(Math.max(0, 8 - visualWidth))
|
|
109
|
+
const statusStr = indicators.length > 0 ? indicators.join(' ') + padding : ' '
|
|
110
|
+
|
|
111
|
+
console.log(`${statusStr} ${change._number} ${change.subject}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import { type ConfigError, ConfigService } from '@/services/config'
|
|
4
|
+
import { extractChangeNumber, isValidChangeId } from '@/utils/url-parser'
|
|
5
|
+
import { sanitizeUrl, getOpenCommand } from '@/utils/shell-safety'
|
|
6
|
+
import { exec } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
interface OpenOptions {
|
|
9
|
+
// No options for now, but keeping the structure for future extensibility
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const openCommand = (
|
|
13
|
+
changeId: string,
|
|
14
|
+
_options: OpenOptions = {},
|
|
15
|
+
): Effect.Effect<void, ApiError | ConfigError | Error, GerritApiService | ConfigService> =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const gerritApi = yield* GerritApiService
|
|
18
|
+
const configService = yield* ConfigService
|
|
19
|
+
|
|
20
|
+
// Extract change number if a URL was provided
|
|
21
|
+
const cleanChangeId = extractChangeNumber(changeId)
|
|
22
|
+
|
|
23
|
+
// Validate the change ID
|
|
24
|
+
if (!isValidChangeId(cleanChangeId)) {
|
|
25
|
+
yield* Effect.fail(new Error(`Invalid change ID: ${cleanChangeId}`))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fetch change details to get the project name
|
|
29
|
+
const change = yield* gerritApi.getChange(cleanChangeId)
|
|
30
|
+
|
|
31
|
+
// Get the Gerrit host from config
|
|
32
|
+
const credentials = yield* configService.getCredentials
|
|
33
|
+
const gerritHost = credentials.host
|
|
34
|
+
|
|
35
|
+
const changeUrl = `${gerritHost}/c/${change.project}/+/${change._number}`
|
|
36
|
+
|
|
37
|
+
// Sanitize URL for shell safety
|
|
38
|
+
const safeUrl = yield* sanitizeUrl(changeUrl)
|
|
39
|
+
|
|
40
|
+
// Open the URL in the default browser
|
|
41
|
+
const openCmd = getOpenCommand()
|
|
42
|
+
|
|
43
|
+
yield* Effect.promise(
|
|
44
|
+
() =>
|
|
45
|
+
new Promise<void>((resolve, reject) => {
|
|
46
|
+
exec(`${openCmd} "${safeUrl}"`, (error) => {
|
|
47
|
+
if (error) {
|
|
48
|
+
reject(new Error(`Failed to open URL: ${error.message}`))
|
|
49
|
+
} else {
|
|
50
|
+
resolve()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
console.log(`Opened: ${changeUrl}`)
|
|
57
|
+
})
|