@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,230 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { Effect, pipe, Console } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
ConfigService,
|
|
5
|
+
ConfigServiceLive,
|
|
6
|
+
ConfigError,
|
|
7
|
+
type ConfigServiceImpl,
|
|
8
|
+
} from '@/services/config'
|
|
9
|
+
import type { GerritCredentials } from '@/schemas/gerrit'
|
|
10
|
+
import { AppConfig } from '@/schemas/config'
|
|
11
|
+
import { Schema } from '@effect/schema'
|
|
12
|
+
import { input, password } from '@inquirer/prompts'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
// Check if a command exists on the system
|
|
16
|
+
const checkCommandExists = (command: string): Promise<boolean> =>
|
|
17
|
+
new Promise((resolve) => {
|
|
18
|
+
const child = spawn('which', [command], { stdio: 'ignore' })
|
|
19
|
+
child.on('close', (code) => {
|
|
20
|
+
resolve(code === 0)
|
|
21
|
+
})
|
|
22
|
+
child.on('error', () => {
|
|
23
|
+
resolve(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// AI tools to check for in order of preference
|
|
28
|
+
const AI_TOOLS = ['claude', 'llm', 'opencode', 'gemini'] as const
|
|
29
|
+
|
|
30
|
+
// Effect wrapper for detecting available AI tools
|
|
31
|
+
const detectAvailableAITools = () =>
|
|
32
|
+
Effect.tryPromise({
|
|
33
|
+
try: async () => {
|
|
34
|
+
const availableTools: string[] = []
|
|
35
|
+
|
|
36
|
+
for (const tool of AI_TOOLS) {
|
|
37
|
+
const exists = await checkCommandExists(tool)
|
|
38
|
+
if (exists) {
|
|
39
|
+
availableTools.push(tool)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return availableTools
|
|
44
|
+
},
|
|
45
|
+
catch: (error) => new ConfigError({ message: `Failed to detect AI tools: ${error}` }),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Effect wrapper for getting existing config
|
|
49
|
+
const getExistingConfig = (configService: ConfigServiceImpl) =>
|
|
50
|
+
configService.getFullConfig.pipe(Effect.orElseSucceed(() => null))
|
|
51
|
+
|
|
52
|
+
// Test connection with credentials
|
|
53
|
+
const verifyCredentials = (credentials: GerritCredentials) =>
|
|
54
|
+
Effect.tryPromise({
|
|
55
|
+
try: async () => {
|
|
56
|
+
const auth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')
|
|
57
|
+
const response = await fetch(`${credentials.host}/a/config/server/version`, {
|
|
58
|
+
headers: { Authorization: `Basic ${auth}` },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Authentication failed: ${response.status}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return response.ok
|
|
66
|
+
},
|
|
67
|
+
catch: (error) => {
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
if (error.message.includes('401')) {
|
|
70
|
+
return new ConfigError({
|
|
71
|
+
message: 'Invalid credentials. Please check your username and password.',
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
if (error.message.includes('ENOTFOUND')) {
|
|
75
|
+
return new ConfigError({ message: 'Could not connect to Gerrit. Please check the URL.' })
|
|
76
|
+
}
|
|
77
|
+
return new ConfigError({ message: error.message })
|
|
78
|
+
}
|
|
79
|
+
return new ConfigError({ message: 'Unknown error occurred' })
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Pure Effect-based setup implementation using inquirer
|
|
84
|
+
const setupEffect = (configService: ConfigServiceImpl) =>
|
|
85
|
+
pipe(
|
|
86
|
+
Effect.all([getExistingConfig(configService), detectAvailableAITools()]),
|
|
87
|
+
Effect.flatMap(([existingConfig, availableTools]) =>
|
|
88
|
+
pipe(
|
|
89
|
+
Console.log(chalk.bold('🔧 Gerrit CLI Setup')),
|
|
90
|
+
Effect.flatMap(() => Console.log('')),
|
|
91
|
+
Effect.flatMap(() => {
|
|
92
|
+
if (existingConfig) {
|
|
93
|
+
return Console.log(chalk.dim('(Press Enter to keep existing values)'))
|
|
94
|
+
} else {
|
|
95
|
+
return pipe(
|
|
96
|
+
Console.log(chalk.cyan('Please provide your Gerrit connection details:')),
|
|
97
|
+
Effect.flatMap(() =>
|
|
98
|
+
Console.log(chalk.dim('Example URL: https://gerrit.example.com')),
|
|
99
|
+
),
|
|
100
|
+
Effect.flatMap(() =>
|
|
101
|
+
Console.log(
|
|
102
|
+
chalk.dim(
|
|
103
|
+
'You can find your HTTP password in Gerrit Settings > HTTP Credentials',
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
Effect.flatMap(() =>
|
|
111
|
+
Effect.tryPromise({
|
|
112
|
+
try: async () => {
|
|
113
|
+
console.log('')
|
|
114
|
+
|
|
115
|
+
// Gerrit Host URL
|
|
116
|
+
const host = await input({
|
|
117
|
+
message: 'Gerrit Host URL (e.g., https://gerrit.example.com)',
|
|
118
|
+
default: existingConfig?.host,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Username
|
|
122
|
+
const username = await input({
|
|
123
|
+
message: 'Username (your Gerrit username)',
|
|
124
|
+
default: existingConfig?.username,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Password - similar to ji's pattern
|
|
128
|
+
const passwordValue =
|
|
129
|
+
(await password({
|
|
130
|
+
message: 'HTTP Password (generated from Gerrit settings)',
|
|
131
|
+
})) ||
|
|
132
|
+
existingConfig?.password ||
|
|
133
|
+
''
|
|
134
|
+
|
|
135
|
+
console.log('')
|
|
136
|
+
console.log(chalk.yellow('Optional: AI Configuration'))
|
|
137
|
+
|
|
138
|
+
// Show detected AI tools
|
|
139
|
+
if (availableTools.length > 0) {
|
|
140
|
+
console.log(chalk.dim(`Detected AI tools: ${availableTools.join(', ')}`))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Get default suggestion
|
|
144
|
+
const defaultCommand =
|
|
145
|
+
existingConfig?.aiTool ||
|
|
146
|
+
(availableTools.includes('claude') ? 'claude' : availableTools[0]) ||
|
|
147
|
+
''
|
|
148
|
+
|
|
149
|
+
// AI tool command with smart default
|
|
150
|
+
const aiToolCommand = await input({
|
|
151
|
+
message:
|
|
152
|
+
availableTools.length > 0
|
|
153
|
+
? 'AI tool command (detected from system)'
|
|
154
|
+
: 'AI tool command (e.g., claude, llm, opencode, gemini)',
|
|
155
|
+
default: defaultCommand || 'claude',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Build flat config
|
|
159
|
+
const configData = {
|
|
160
|
+
host: host.trim().replace(/\/$/, ''), // Remove trailing slash
|
|
161
|
+
username: username.trim(),
|
|
162
|
+
password: passwordValue,
|
|
163
|
+
...(aiToolCommand && {
|
|
164
|
+
aiTool: aiToolCommand,
|
|
165
|
+
}),
|
|
166
|
+
aiAutoDetect: !aiToolCommand,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate config using Schema instead of type assertion
|
|
170
|
+
const fullConfig = Schema.decodeUnknownSync(AppConfig)(configData)
|
|
171
|
+
|
|
172
|
+
return fullConfig
|
|
173
|
+
},
|
|
174
|
+
catch: (error) => {
|
|
175
|
+
if (error instanceof Error && error.message.includes('User force closed')) {
|
|
176
|
+
console.log(`\n${chalk.yellow('Setup cancelled')}`)
|
|
177
|
+
process.exit(0)
|
|
178
|
+
}
|
|
179
|
+
return new ConfigError({
|
|
180
|
+
message: error instanceof Error ? error.message : 'Failed to get user input',
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
Effect.tap(() => Console.log('\nVerifying credentials...')),
|
|
188
|
+
Effect.flatMap((config) =>
|
|
189
|
+
pipe(
|
|
190
|
+
verifyCredentials({
|
|
191
|
+
host: config.host,
|
|
192
|
+
username: config.username,
|
|
193
|
+
password: config.password,
|
|
194
|
+
}),
|
|
195
|
+
Effect.map(() => config),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
Effect.tap(() => Console.log(chalk.green('Successfully authenticated'))),
|
|
199
|
+
Effect.flatMap((config) => configService.saveFullConfig(config)),
|
|
200
|
+
Effect.tap(() => Console.log(chalk.green('\nConfiguration saved successfully!'))),
|
|
201
|
+
Effect.tap(() => Console.log('You can now use:')),
|
|
202
|
+
Effect.tap(() => Console.log(' • "ger mine" to view your changes')),
|
|
203
|
+
Effect.tap(() => Console.log(' • "ger show <change-id>" to view change details')),
|
|
204
|
+
Effect.tap(() => Console.log(' • "ger review <change-id>" to review with AI')),
|
|
205
|
+
Effect.catchAll((error) =>
|
|
206
|
+
pipe(
|
|
207
|
+
Console.error(
|
|
208
|
+
chalk.red(
|
|
209
|
+
`\nAuthentication failed: ${error instanceof ConfigError ? error.message : 'Unknown error'}`,
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
Effect.flatMap(() => Console.error('Please check your credentials and try again.')),
|
|
213
|
+
Effect.flatMap(() => Effect.fail(error)),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
export async function setup() {
|
|
219
|
+
const program = pipe(
|
|
220
|
+
ConfigService,
|
|
221
|
+
Effect.flatMap((configService) => setupEffect(configService)),
|
|
222
|
+
).pipe(Effect.provide(ConfigServiceLive))
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await Effect.runPromise(program)
|
|
226
|
+
} catch {
|
|
227
|
+
// Error already handled and displayed
|
|
228
|
+
process.exit(1)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { formatCommentsPretty } from '@/utils/comment-formatters'
|
|
5
|
+
import { getDiffContext } from '@/utils/diff-context'
|
|
6
|
+
import { formatDiffPretty } from '@/utils/diff-formatters'
|
|
7
|
+
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
8
|
+
import { formatDate } from '@/utils/formatters'
|
|
9
|
+
import { sortMessagesByDate } from '@/utils/message-filters'
|
|
10
|
+
|
|
11
|
+
interface ShowOptions {
|
|
12
|
+
xml?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ChangeDetails {
|
|
16
|
+
id: string
|
|
17
|
+
number: number
|
|
18
|
+
subject: string
|
|
19
|
+
status: string
|
|
20
|
+
project: string
|
|
21
|
+
branch: string
|
|
22
|
+
owner: {
|
|
23
|
+
name?: string
|
|
24
|
+
email?: string
|
|
25
|
+
}
|
|
26
|
+
created?: string
|
|
27
|
+
updated?: string
|
|
28
|
+
commitMessage: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getChangeDetails = (
|
|
32
|
+
changeId: string,
|
|
33
|
+
): Effect.Effect<ChangeDetails, ApiError, GerritApiService> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const gerritApi = yield* GerritApiService
|
|
36
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: change.change_id,
|
|
40
|
+
number: change._number,
|
|
41
|
+
subject: change.subject,
|
|
42
|
+
status: change.status,
|
|
43
|
+
project: change.project,
|
|
44
|
+
branch: change.branch,
|
|
45
|
+
owner: {
|
|
46
|
+
name: change.owner?.name,
|
|
47
|
+
email: change.owner?.email,
|
|
48
|
+
},
|
|
49
|
+
created: change.created,
|
|
50
|
+
updated: change.updated,
|
|
51
|
+
commitMessage: change.subject, // For now, using subject as commit message
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const getDiffForChange = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const gerritApi = yield* GerritApiService
|
|
58
|
+
const diff = yield* gerritApi.getDiff(changeId, { format: 'unified' })
|
|
59
|
+
return typeof diff === 'string' ? diff : JSON.stringify(diff, null, 2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const getCommentsAndMessagesForChange = (
|
|
63
|
+
changeId: string,
|
|
64
|
+
): Effect.Effect<
|
|
65
|
+
{ comments: CommentInfo[]; messages: MessageInfo[] },
|
|
66
|
+
ApiError,
|
|
67
|
+
GerritApiService
|
|
68
|
+
> =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const gerritApi = yield* GerritApiService
|
|
71
|
+
|
|
72
|
+
// Get both inline comments and review messages concurrently
|
|
73
|
+
const [comments, messages] = yield* Effect.all(
|
|
74
|
+
[gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
|
|
75
|
+
{ concurrency: 'unbounded' },
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// Flatten all inline comments from all files
|
|
79
|
+
const allComments: CommentInfo[] = []
|
|
80
|
+
for (const [path, fileComments] of Object.entries(comments)) {
|
|
81
|
+
for (const comment of fileComments) {
|
|
82
|
+
allComments.push({
|
|
83
|
+
...comment,
|
|
84
|
+
path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sort inline comments by path and then by line number
|
|
90
|
+
allComments.sort((a, b) => {
|
|
91
|
+
const pathCompare = (a.path || '').localeCompare(b.path || '')
|
|
92
|
+
if (pathCompare !== 0) return pathCompare
|
|
93
|
+
return (a.line || 0) - (b.line || 0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Sort messages by date (newest first)
|
|
97
|
+
const sortedMessages = sortMessagesByDate(messages)
|
|
98
|
+
|
|
99
|
+
return { comments: allComments, messages: sortedMessages }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const formatShowPretty = (
|
|
103
|
+
changeDetails: ChangeDetails,
|
|
104
|
+
diff: string,
|
|
105
|
+
commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
|
|
106
|
+
messages: MessageInfo[],
|
|
107
|
+
): void => {
|
|
108
|
+
// Change details header
|
|
109
|
+
console.log('━'.repeat(80))
|
|
110
|
+
console.log(`📋 Change ${changeDetails.number}: ${changeDetails.subject}`)
|
|
111
|
+
console.log('━'.repeat(80))
|
|
112
|
+
console.log()
|
|
113
|
+
|
|
114
|
+
// Metadata
|
|
115
|
+
console.log('📝 Details:')
|
|
116
|
+
console.log(` Project: ${changeDetails.project}`)
|
|
117
|
+
console.log(` Branch: ${changeDetails.branch}`)
|
|
118
|
+
console.log(` Status: ${changeDetails.status}`)
|
|
119
|
+
console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
|
|
120
|
+
console.log(
|
|
121
|
+
` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
|
|
122
|
+
)
|
|
123
|
+
console.log(
|
|
124
|
+
` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`,
|
|
125
|
+
)
|
|
126
|
+
console.log(` Change-Id: ${changeDetails.id}`)
|
|
127
|
+
console.log()
|
|
128
|
+
|
|
129
|
+
// Diff section
|
|
130
|
+
console.log('🔍 Diff:')
|
|
131
|
+
console.log('─'.repeat(40))
|
|
132
|
+
console.log(formatDiffPretty(diff))
|
|
133
|
+
console.log()
|
|
134
|
+
|
|
135
|
+
// Comments and Messages section
|
|
136
|
+
const hasComments = commentsWithContext.length > 0
|
|
137
|
+
const hasMessages = messages.length > 0
|
|
138
|
+
|
|
139
|
+
if (hasComments) {
|
|
140
|
+
console.log('💬 Inline Comments:')
|
|
141
|
+
console.log('─'.repeat(40))
|
|
142
|
+
formatCommentsPretty(commentsWithContext)
|
|
143
|
+
console.log()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (hasMessages) {
|
|
147
|
+
console.log('📝 Review Activity:')
|
|
148
|
+
console.log('─'.repeat(40))
|
|
149
|
+
for (const message of messages) {
|
|
150
|
+
const author = message.author?.name || 'Unknown'
|
|
151
|
+
const date = formatDate(message.date)
|
|
152
|
+
const cleanMessage = message.message.trim()
|
|
153
|
+
|
|
154
|
+
// Skip very short automated messages
|
|
155
|
+
if (
|
|
156
|
+
cleanMessage.length < 10 &&
|
|
157
|
+
(cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
|
|
158
|
+
) {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`📅 ${date} - ${author}`)
|
|
163
|
+
console.log(` ${cleanMessage}`)
|
|
164
|
+
console.log()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!hasComments && !hasMessages) {
|
|
169
|
+
console.log('💬 Comments & Activity:')
|
|
170
|
+
console.log('─'.repeat(40))
|
|
171
|
+
console.log('No comments or review activity found.')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const formatShowXml = (
|
|
176
|
+
changeDetails: ChangeDetails,
|
|
177
|
+
diff: string,
|
|
178
|
+
commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
|
|
179
|
+
messages: MessageInfo[],
|
|
180
|
+
): void => {
|
|
181
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
182
|
+
console.log(`<show_result>`)
|
|
183
|
+
console.log(` <status>success</status>`)
|
|
184
|
+
console.log(` <change>`)
|
|
185
|
+
console.log(` <id>${escapeXML(changeDetails.id)}</id>`)
|
|
186
|
+
console.log(` <number>${changeDetails.number}</number>`)
|
|
187
|
+
console.log(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
|
|
188
|
+
console.log(` <status>${escapeXML(changeDetails.status)}</status>`)
|
|
189
|
+
console.log(` <project>${escapeXML(changeDetails.project)}</project>`)
|
|
190
|
+
console.log(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
|
|
191
|
+
console.log(` <owner>`)
|
|
192
|
+
if (changeDetails.owner.name) {
|
|
193
|
+
console.log(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
|
|
194
|
+
}
|
|
195
|
+
if (changeDetails.owner.email) {
|
|
196
|
+
console.log(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
|
|
197
|
+
}
|
|
198
|
+
console.log(` </owner>`)
|
|
199
|
+
console.log(` <created>${escapeXML(changeDetails.created || '')}</created>`)
|
|
200
|
+
console.log(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
|
|
201
|
+
console.log(` </change>`)
|
|
202
|
+
console.log(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
|
|
203
|
+
|
|
204
|
+
// Comments section
|
|
205
|
+
console.log(` <comments>`)
|
|
206
|
+
console.log(` <count>${commentsWithContext.length}</count>`)
|
|
207
|
+
for (const { comment } of commentsWithContext) {
|
|
208
|
+
console.log(` <comment>`)
|
|
209
|
+
if (comment.id) console.log(` <id>${escapeXML(comment.id)}</id>`)
|
|
210
|
+
if (comment.path) console.log(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
|
|
211
|
+
if (comment.line) console.log(` <line>${comment.line}</line>`)
|
|
212
|
+
if (comment.author?.name) {
|
|
213
|
+
console.log(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
214
|
+
}
|
|
215
|
+
if (comment.updated) console.log(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
216
|
+
if (comment.message) {
|
|
217
|
+
console.log(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
218
|
+
}
|
|
219
|
+
if (comment.unresolved) console.log(` <unresolved>true</unresolved>`)
|
|
220
|
+
console.log(` </comment>`)
|
|
221
|
+
}
|
|
222
|
+
console.log(` </comments>`)
|
|
223
|
+
|
|
224
|
+
// Messages section
|
|
225
|
+
console.log(` <messages>`)
|
|
226
|
+
console.log(` <count>${messages.length}</count>`)
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
console.log(` <message>`)
|
|
229
|
+
console.log(` <id>${escapeXML(message.id)}</id>`)
|
|
230
|
+
if (message.author?.name) {
|
|
231
|
+
console.log(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
232
|
+
}
|
|
233
|
+
if (message.author?._account_id) {
|
|
234
|
+
console.log(` <author_id>${message.author._account_id}</author_id>`)
|
|
235
|
+
}
|
|
236
|
+
console.log(` <date>${escapeXML(message.date)}</date>`)
|
|
237
|
+
if (message._revision_number) {
|
|
238
|
+
console.log(` <revision>${message._revision_number}</revision>`)
|
|
239
|
+
}
|
|
240
|
+
if (message.tag) {
|
|
241
|
+
console.log(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
242
|
+
}
|
|
243
|
+
console.log(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
244
|
+
console.log(` </message>`)
|
|
245
|
+
}
|
|
246
|
+
console.log(` </messages>`)
|
|
247
|
+
console.log(`</show_result>`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const showCommand = (
|
|
251
|
+
changeId: string,
|
|
252
|
+
options: ShowOptions,
|
|
253
|
+
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
// Fetch all data concurrently
|
|
256
|
+
const [changeDetails, diff, commentsAndMessages] = yield* Effect.all(
|
|
257
|
+
[
|
|
258
|
+
getChangeDetails(changeId),
|
|
259
|
+
getDiffForChange(changeId),
|
|
260
|
+
getCommentsAndMessagesForChange(changeId),
|
|
261
|
+
],
|
|
262
|
+
{ concurrency: 'unbounded' },
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const { comments, messages } = commentsAndMessages
|
|
266
|
+
|
|
267
|
+
// Get context for each comment using concurrent requests
|
|
268
|
+
const contextEffects = comments.map((comment) =>
|
|
269
|
+
comment.path && comment.line
|
|
270
|
+
? getDiffContext(changeId, comment.path, comment.line).pipe(
|
|
271
|
+
Effect.map((context) => ({ comment, context })),
|
|
272
|
+
// Graceful degradation for diff fetch failures
|
|
273
|
+
Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
|
|
274
|
+
)
|
|
275
|
+
: Effect.succeed({ comment, context: undefined }),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// Execute all context fetches concurrently
|
|
279
|
+
const commentsWithContext = yield* Effect.all(contextEffects, {
|
|
280
|
+
concurrency: 'unbounded',
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Format output
|
|
284
|
+
if (options.xml) {
|
|
285
|
+
formatShowXml(changeDetails, diff, commentsWithContext, messages)
|
|
286
|
+
} else {
|
|
287
|
+
formatShowPretty(changeDetails, diff, commentsWithContext, messages)
|
|
288
|
+
}
|
|
289
|
+
}).pipe(
|
|
290
|
+
// Regional error boundary for the entire command
|
|
291
|
+
Effect.catchTag('ApiError', (error) => {
|
|
292
|
+
if (options.xml) {
|
|
293
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
294
|
+
console.log(`<show_result>`)
|
|
295
|
+
console.log(` <status>error</status>`)
|
|
296
|
+
console.log(` <error><![CDATA[${error.message}]]></error>`)
|
|
297
|
+
console.log(`</show_result>`)
|
|
298
|
+
} else {
|
|
299
|
+
console.error(`✗ Failed to fetch change details: ${error.message}`)
|
|
300
|
+
}
|
|
301
|
+
return Effect.succeed(undefined)
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface StatusOptions {
|
|
5
|
+
xml?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const statusCommand = (
|
|
9
|
+
options: StatusOptions,
|
|
10
|
+
): Effect.Effect<void, Error, GerritApiService> =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const apiService = yield* GerritApiService
|
|
13
|
+
|
|
14
|
+
const isConnected = yield* apiService.testConnection
|
|
15
|
+
|
|
16
|
+
if (options.xml) {
|
|
17
|
+
// XML output for LLM consumption
|
|
18
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
19
|
+
console.log(`<status_result>`)
|
|
20
|
+
console.log(` <connected>${isConnected}</connected>`)
|
|
21
|
+
console.log(`</status_result>`)
|
|
22
|
+
} else {
|
|
23
|
+
// Pretty output by default
|
|
24
|
+
if (isConnected) {
|
|
25
|
+
console.log('✓ Connected to Gerrit successfully!')
|
|
26
|
+
} else {
|
|
27
|
+
console.log('✗ Failed to connect to Gerrit')
|
|
28
|
+
console.log('Please check your credentials and network connection')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isConnected) {
|
|
33
|
+
yield* Effect.fail(new Error('Connection failed'))
|
|
34
|
+
}
|
|
35
|
+
})
|