@aaronshaf/ger 1.2.10 → 2.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/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -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 +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -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/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -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/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -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/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -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 +708 -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/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -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/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -180
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- package/src/utils.ts +0 -130
|
@@ -0,0 +1,491 @@
|
|
|
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 { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
|
|
10
|
+
import { writeFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
export const SHOW_HELP_TEXT = `
|
|
13
|
+
Examples:
|
|
14
|
+
# Show specific change (using change number)
|
|
15
|
+
$ ger show 392385
|
|
16
|
+
|
|
17
|
+
# Show specific change (using Change-ID)
|
|
18
|
+
$ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
19
|
+
|
|
20
|
+
# Auto-detect Change-ID from HEAD commit
|
|
21
|
+
$ ger show
|
|
22
|
+
$ ger show --xml
|
|
23
|
+
$ ger show --json
|
|
24
|
+
|
|
25
|
+
# Extract build failure URL with jq
|
|
26
|
+
$ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
|
|
27
|
+
|
|
28
|
+
Note: When no change-id is provided, it will be automatically extracted from the
|
|
29
|
+
Change-ID footer in your HEAD commit. You must be in a git repository with
|
|
30
|
+
a commit that has a Change-ID.`
|
|
31
|
+
|
|
32
|
+
interface ShowOptions {
|
|
33
|
+
xml?: boolean
|
|
34
|
+
json?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ChangeDetails {
|
|
38
|
+
id: string
|
|
39
|
+
number: number
|
|
40
|
+
subject: string
|
|
41
|
+
status: string
|
|
42
|
+
project: string
|
|
43
|
+
branch: string
|
|
44
|
+
owner: {
|
|
45
|
+
name?: string
|
|
46
|
+
email?: string
|
|
47
|
+
}
|
|
48
|
+
created?: string
|
|
49
|
+
updated?: string
|
|
50
|
+
commitMessage: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const getChangeDetails = (
|
|
54
|
+
changeId: string,
|
|
55
|
+
): Effect.Effect<ChangeDetails, ApiError, GerritApiService> =>
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const gerritApi = yield* GerritApiService
|
|
58
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: change.change_id,
|
|
62
|
+
number: change._number,
|
|
63
|
+
subject: change.subject,
|
|
64
|
+
status: change.status,
|
|
65
|
+
project: change.project,
|
|
66
|
+
branch: change.branch,
|
|
67
|
+
owner: {
|
|
68
|
+
name: change.owner?.name,
|
|
69
|
+
email: change.owner?.email,
|
|
70
|
+
},
|
|
71
|
+
created: change.created,
|
|
72
|
+
updated: change.updated,
|
|
73
|
+
commitMessage: change.subject, // For now, using subject as commit message
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const getDiffForChange = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const gerritApi = yield* GerritApiService
|
|
80
|
+
const diff = yield* gerritApi.getDiff(changeId, { format: 'unified' })
|
|
81
|
+
return typeof diff === 'string' ? diff : JSON.stringify(diff, null, 2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const getCommentsAndMessagesForChange = (
|
|
85
|
+
changeId: string,
|
|
86
|
+
): Effect.Effect<
|
|
87
|
+
{ comments: CommentInfo[]; messages: MessageInfo[] },
|
|
88
|
+
ApiError,
|
|
89
|
+
GerritApiService
|
|
90
|
+
> =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const gerritApi = yield* GerritApiService
|
|
93
|
+
|
|
94
|
+
// Get both inline comments and review messages concurrently
|
|
95
|
+
const [comments, messages] = yield* Effect.all(
|
|
96
|
+
[gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
|
|
97
|
+
{ concurrency: 'unbounded' },
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Flatten all inline comments from all files
|
|
101
|
+
const allComments: CommentInfo[] = []
|
|
102
|
+
for (const [path, fileComments] of Object.entries(comments)) {
|
|
103
|
+
for (const comment of fileComments) {
|
|
104
|
+
allComments.push({
|
|
105
|
+
...comment,
|
|
106
|
+
path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sort inline comments by date (ascending - oldest first)
|
|
112
|
+
allComments.sort((a, b) => {
|
|
113
|
+
const dateA = a.updated ? new Date(a.updated).getTime() : 0
|
|
114
|
+
const dateB = b.updated ? new Date(b.updated).getTime() : 0
|
|
115
|
+
return dateA - dateB
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Sort messages by date (ascending - oldest first)
|
|
119
|
+
const sortedMessages = [...messages].sort((a, b) => {
|
|
120
|
+
const dateA = new Date(a.date).getTime()
|
|
121
|
+
const dateB = new Date(b.date).getTime()
|
|
122
|
+
return dateA - dateB
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return { comments: allComments, messages: sortedMessages }
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const formatShowPretty = (
|
|
129
|
+
changeDetails: ChangeDetails,
|
|
130
|
+
diff: string,
|
|
131
|
+
commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
|
|
132
|
+
messages: MessageInfo[],
|
|
133
|
+
): void => {
|
|
134
|
+
// Change details header
|
|
135
|
+
console.log('━'.repeat(80))
|
|
136
|
+
console.log(`📋 Change ${changeDetails.number}: ${changeDetails.subject}`)
|
|
137
|
+
console.log('━'.repeat(80))
|
|
138
|
+
console.log()
|
|
139
|
+
|
|
140
|
+
// Metadata
|
|
141
|
+
console.log('📝 Details:')
|
|
142
|
+
console.log(` Project: ${changeDetails.project}`)
|
|
143
|
+
console.log(` Branch: ${changeDetails.branch}`)
|
|
144
|
+
console.log(` Status: ${changeDetails.status}`)
|
|
145
|
+
console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
|
|
146
|
+
console.log(
|
|
147
|
+
` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
|
|
148
|
+
)
|
|
149
|
+
console.log(
|
|
150
|
+
` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`,
|
|
151
|
+
)
|
|
152
|
+
console.log(` Change-Id: ${changeDetails.id}`)
|
|
153
|
+
console.log()
|
|
154
|
+
|
|
155
|
+
// Diff section
|
|
156
|
+
console.log('🔍 Diff:')
|
|
157
|
+
console.log('─'.repeat(40))
|
|
158
|
+
console.log(formatDiffPretty(diff))
|
|
159
|
+
console.log()
|
|
160
|
+
|
|
161
|
+
// Comments and Messages section
|
|
162
|
+
const hasComments = commentsWithContext.length > 0
|
|
163
|
+
const hasMessages = messages.length > 0
|
|
164
|
+
|
|
165
|
+
if (hasComments) {
|
|
166
|
+
console.log('💬 Inline Comments:')
|
|
167
|
+
console.log('─'.repeat(40))
|
|
168
|
+
formatCommentsPretty(commentsWithContext)
|
|
169
|
+
console.log()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (hasMessages) {
|
|
173
|
+
console.log('📝 Review Activity:')
|
|
174
|
+
console.log('─'.repeat(40))
|
|
175
|
+
for (const message of messages) {
|
|
176
|
+
const author = message.author?.name || 'Unknown'
|
|
177
|
+
const date = formatDate(message.date)
|
|
178
|
+
const cleanMessage = message.message.trim()
|
|
179
|
+
|
|
180
|
+
// Skip very short automated messages
|
|
181
|
+
if (
|
|
182
|
+
cleanMessage.length < 10 &&
|
|
183
|
+
(cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
|
|
184
|
+
) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`📅 ${date} - ${author}`)
|
|
189
|
+
console.log(` ${cleanMessage}`)
|
|
190
|
+
console.log()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!hasComments && !hasMessages) {
|
|
195
|
+
console.log('💬 Comments & Activity:')
|
|
196
|
+
console.log('─'.repeat(40))
|
|
197
|
+
console.log('No comments or review activity found.')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Helper to remove undefined values from objects
|
|
202
|
+
const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => {
|
|
203
|
+
return Object.fromEntries(
|
|
204
|
+
Object.entries(obj).filter(([_, value]) => value !== undefined),
|
|
205
|
+
) as Partial<T>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const formatShowJson = async (
|
|
209
|
+
changeDetails: ChangeDetails,
|
|
210
|
+
diff: string,
|
|
211
|
+
commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
|
|
212
|
+
messages: MessageInfo[],
|
|
213
|
+
): Promise<void> => {
|
|
214
|
+
const output = {
|
|
215
|
+
status: 'success',
|
|
216
|
+
change: removeUndefined({
|
|
217
|
+
id: changeDetails.id,
|
|
218
|
+
number: changeDetails.number,
|
|
219
|
+
subject: changeDetails.subject,
|
|
220
|
+
status: changeDetails.status,
|
|
221
|
+
project: changeDetails.project,
|
|
222
|
+
branch: changeDetails.branch,
|
|
223
|
+
owner: removeUndefined(changeDetails.owner),
|
|
224
|
+
created: changeDetails.created,
|
|
225
|
+
updated: changeDetails.updated,
|
|
226
|
+
}),
|
|
227
|
+
diff,
|
|
228
|
+
comments: commentsWithContext.map(({ comment, context }) =>
|
|
229
|
+
removeUndefined({
|
|
230
|
+
id: comment.id,
|
|
231
|
+
path: comment.path,
|
|
232
|
+
line: comment.line,
|
|
233
|
+
range: comment.range,
|
|
234
|
+
author: comment.author
|
|
235
|
+
? removeUndefined({
|
|
236
|
+
name: comment.author.name,
|
|
237
|
+
email: comment.author.email,
|
|
238
|
+
account_id: comment.author._account_id,
|
|
239
|
+
})
|
|
240
|
+
: undefined,
|
|
241
|
+
updated: comment.updated,
|
|
242
|
+
message: comment.message,
|
|
243
|
+
unresolved: comment.unresolved,
|
|
244
|
+
in_reply_to: comment.in_reply_to,
|
|
245
|
+
context,
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
messages: messages.map((message) =>
|
|
249
|
+
removeUndefined({
|
|
250
|
+
id: message.id,
|
|
251
|
+
author: message.author
|
|
252
|
+
? removeUndefined({
|
|
253
|
+
name: message.author.name,
|
|
254
|
+
email: message.author.email,
|
|
255
|
+
account_id: message.author._account_id,
|
|
256
|
+
})
|
|
257
|
+
: undefined,
|
|
258
|
+
date: message.date,
|
|
259
|
+
message: message.message,
|
|
260
|
+
revision: message._revision_number,
|
|
261
|
+
tag: message.tag,
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const jsonOutput = JSON.stringify(output, null, 2) + '\n'
|
|
267
|
+
// Write to stdout and ensure all data is flushed before process exits
|
|
268
|
+
// Using process.stdout.write with drain handling for large payloads
|
|
269
|
+
return new Promise<void>((resolve, reject) => {
|
|
270
|
+
const written = process.stdout.write(jsonOutput, (err) => {
|
|
271
|
+
if (err) {
|
|
272
|
+
reject(err)
|
|
273
|
+
} else {
|
|
274
|
+
resolve()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
if (!written) {
|
|
279
|
+
// If write returned false, buffer is full, wait for drain
|
|
280
|
+
process.stdout.once('drain', resolve)
|
|
281
|
+
process.stdout.once('error', reject)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const formatShowXml = async (
|
|
287
|
+
changeDetails: ChangeDetails,
|
|
288
|
+
diff: string,
|
|
289
|
+
commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
|
|
290
|
+
messages: MessageInfo[],
|
|
291
|
+
): Promise<void> => {
|
|
292
|
+
// Build complete XML output as a single string to avoid multiple writes
|
|
293
|
+
const xmlParts: string[] = []
|
|
294
|
+
xmlParts.push(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
295
|
+
xmlParts.push(`<show_result>`)
|
|
296
|
+
xmlParts.push(` <status>success</status>`)
|
|
297
|
+
xmlParts.push(` <change>`)
|
|
298
|
+
xmlParts.push(` <id>${escapeXML(changeDetails.id)}</id>`)
|
|
299
|
+
xmlParts.push(` <number>${changeDetails.number}</number>`)
|
|
300
|
+
xmlParts.push(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
|
|
301
|
+
xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
|
|
302
|
+
xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
|
|
303
|
+
xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
|
|
304
|
+
xmlParts.push(` <owner>`)
|
|
305
|
+
if (changeDetails.owner.name) {
|
|
306
|
+
xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
|
|
307
|
+
}
|
|
308
|
+
if (changeDetails.owner.email) {
|
|
309
|
+
xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
|
|
310
|
+
}
|
|
311
|
+
xmlParts.push(` </owner>`)
|
|
312
|
+
xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`)
|
|
313
|
+
xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
|
|
314
|
+
xmlParts.push(` </change>`)
|
|
315
|
+
xmlParts.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
|
|
316
|
+
|
|
317
|
+
// Comments section
|
|
318
|
+
xmlParts.push(` <comments>`)
|
|
319
|
+
xmlParts.push(` <count>${commentsWithContext.length}</count>`)
|
|
320
|
+
for (const { comment } of commentsWithContext) {
|
|
321
|
+
xmlParts.push(` <comment>`)
|
|
322
|
+
if (comment.id) xmlParts.push(` <id>${escapeXML(comment.id)}</id>`)
|
|
323
|
+
if (comment.path) xmlParts.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
|
|
324
|
+
if (comment.line) xmlParts.push(` <line>${comment.line}</line>`)
|
|
325
|
+
if (comment.author?.name) {
|
|
326
|
+
xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
327
|
+
}
|
|
328
|
+
if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
329
|
+
if (comment.message) {
|
|
330
|
+
xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
331
|
+
}
|
|
332
|
+
if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`)
|
|
333
|
+
xmlParts.push(` </comment>`)
|
|
334
|
+
}
|
|
335
|
+
xmlParts.push(` </comments>`)
|
|
336
|
+
|
|
337
|
+
// Messages section
|
|
338
|
+
xmlParts.push(` <messages>`)
|
|
339
|
+
xmlParts.push(` <count>${messages.length}</count>`)
|
|
340
|
+
for (const message of messages) {
|
|
341
|
+
xmlParts.push(` <message>`)
|
|
342
|
+
xmlParts.push(` <id>${escapeXML(message.id)}</id>`)
|
|
343
|
+
if (message.author?.name) {
|
|
344
|
+
xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
345
|
+
}
|
|
346
|
+
if (message.author?._account_id) {
|
|
347
|
+
xmlParts.push(` <author_id>${message.author._account_id}</author_id>`)
|
|
348
|
+
}
|
|
349
|
+
xmlParts.push(` <date>${escapeXML(message.date)}</date>`)
|
|
350
|
+
if (message._revision_number) {
|
|
351
|
+
xmlParts.push(` <revision>${message._revision_number}</revision>`)
|
|
352
|
+
}
|
|
353
|
+
if (message.tag) {
|
|
354
|
+
xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
355
|
+
}
|
|
356
|
+
xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
357
|
+
xmlParts.push(` </message>`)
|
|
358
|
+
}
|
|
359
|
+
xmlParts.push(` </messages>`)
|
|
360
|
+
xmlParts.push(`</show_result>`)
|
|
361
|
+
|
|
362
|
+
const xmlOutput = xmlParts.join('\n') + '\n'
|
|
363
|
+
// Write to stdout with proper drain handling for large payloads
|
|
364
|
+
return new Promise<void>((resolve, reject) => {
|
|
365
|
+
const written = process.stdout.write(xmlOutput, (err) => {
|
|
366
|
+
if (err) {
|
|
367
|
+
reject(err)
|
|
368
|
+
} else {
|
|
369
|
+
resolve()
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
if (!written) {
|
|
374
|
+
process.stdout.once('drain', resolve)
|
|
375
|
+
process.stdout.once('error', reject)
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export const showCommand = (
|
|
381
|
+
changeId: string | undefined,
|
|
382
|
+
options: ShowOptions,
|
|
383
|
+
): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
|
|
384
|
+
Effect.gen(function* () {
|
|
385
|
+
// Auto-detect Change-ID from HEAD commit if not provided
|
|
386
|
+
const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
|
|
387
|
+
|
|
388
|
+
// Fetch all data concurrently
|
|
389
|
+
const [changeDetails, diff, commentsAndMessages] = yield* Effect.all(
|
|
390
|
+
[
|
|
391
|
+
getChangeDetails(resolvedChangeId),
|
|
392
|
+
getDiffForChange(resolvedChangeId),
|
|
393
|
+
getCommentsAndMessagesForChange(resolvedChangeId),
|
|
394
|
+
],
|
|
395
|
+
{ concurrency: 'unbounded' },
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
const { comments, messages } = commentsAndMessages
|
|
399
|
+
|
|
400
|
+
// Get context for each comment using concurrent requests
|
|
401
|
+
const contextEffects = comments.map((comment) =>
|
|
402
|
+
comment.path && comment.line
|
|
403
|
+
? getDiffContext(resolvedChangeId, comment.path, comment.line).pipe(
|
|
404
|
+
Effect.map((context) => ({ comment, context })),
|
|
405
|
+
// Graceful degradation for diff fetch failures
|
|
406
|
+
Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
|
|
407
|
+
)
|
|
408
|
+
: Effect.succeed({ comment, context: undefined }),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
// Execute all context fetches concurrently
|
|
412
|
+
const commentsWithContext = yield* Effect.all(contextEffects, {
|
|
413
|
+
concurrency: 'unbounded',
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Format output
|
|
417
|
+
if (options.json) {
|
|
418
|
+
yield* Effect.promise(() =>
|
|
419
|
+
formatShowJson(changeDetails, diff, commentsWithContext, messages),
|
|
420
|
+
)
|
|
421
|
+
} else if (options.xml) {
|
|
422
|
+
yield* Effect.promise(() => formatShowXml(changeDetails, diff, commentsWithContext, messages))
|
|
423
|
+
} else {
|
|
424
|
+
formatShowPretty(changeDetails, diff, commentsWithContext, messages)
|
|
425
|
+
}
|
|
426
|
+
}).pipe(
|
|
427
|
+
// Regional error boundary for the entire command
|
|
428
|
+
Effect.catchAll((error) => {
|
|
429
|
+
const errorMessage =
|
|
430
|
+
error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
|
|
431
|
+
? error.message
|
|
432
|
+
: String(error)
|
|
433
|
+
|
|
434
|
+
if (options.json) {
|
|
435
|
+
return Effect.promise(
|
|
436
|
+
() =>
|
|
437
|
+
new Promise<void>((resolve, reject) => {
|
|
438
|
+
const errorOutput =
|
|
439
|
+
JSON.stringify(
|
|
440
|
+
{
|
|
441
|
+
status: 'error',
|
|
442
|
+
error: errorMessage,
|
|
443
|
+
},
|
|
444
|
+
null,
|
|
445
|
+
2,
|
|
446
|
+
) + '\n'
|
|
447
|
+
const written = process.stdout.write(errorOutput, (err) => {
|
|
448
|
+
if (err) {
|
|
449
|
+
reject(err)
|
|
450
|
+
} else {
|
|
451
|
+
resolve()
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
if (!written) {
|
|
456
|
+
// Wait for drain if buffer is full
|
|
457
|
+
process.stdout.once('drain', resolve)
|
|
458
|
+
process.stdout.once('error', reject)
|
|
459
|
+
}
|
|
460
|
+
}),
|
|
461
|
+
)
|
|
462
|
+
} else if (options.xml) {
|
|
463
|
+
return Effect.promise(
|
|
464
|
+
() =>
|
|
465
|
+
new Promise<void>((resolve, reject) => {
|
|
466
|
+
const xmlError =
|
|
467
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
468
|
+
`<show_result>\n` +
|
|
469
|
+
` <status>error</status>\n` +
|
|
470
|
+
` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>\n` +
|
|
471
|
+
`</show_result>\n`
|
|
472
|
+
const written = process.stdout.write(xmlError, (err) => {
|
|
473
|
+
if (err) {
|
|
474
|
+
reject(err)
|
|
475
|
+
} else {
|
|
476
|
+
resolve()
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
if (!written) {
|
|
481
|
+
process.stdout.once('drain', resolve)
|
|
482
|
+
process.stdout.once('error', reject)
|
|
483
|
+
}
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
} else {
|
|
487
|
+
console.error(`✗ Error: ${errorMessage}`)
|
|
488
|
+
}
|
|
489
|
+
return Effect.succeed(undefined)
|
|
490
|
+
}),
|
|
491
|
+
)
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface SubmitOptions {
|
|
5
|
+
xml?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Submits a Gerrit change for merging after verifying it meets submit requirements.
|
|
10
|
+
*
|
|
11
|
+
* Pre-validates that the change has required approvals and is in the correct state
|
|
12
|
+
* before attempting submission.
|
|
13
|
+
*
|
|
14
|
+
* @param changeId - Change number or Change-ID to submit
|
|
15
|
+
* @param options - Configuration options
|
|
16
|
+
* @param options.xml - Whether to output in XML format for LLM consumption
|
|
17
|
+
* @returns Effect that completes when the change is submitted or validation fails
|
|
18
|
+
*/
|
|
19
|
+
export const submitCommand = (
|
|
20
|
+
changeId?: string,
|
|
21
|
+
options: SubmitOptions = {},
|
|
22
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const gerritApi = yield* GerritApiService
|
|
25
|
+
|
|
26
|
+
if (!changeId || changeId.trim() === '') {
|
|
27
|
+
console.error('✗ Change ID is required')
|
|
28
|
+
console.error(' Usage: ger submit <change-id>')
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pre-check: Fetch change to verify it's submittable
|
|
33
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
34
|
+
|
|
35
|
+
// Check if the change is submittable
|
|
36
|
+
if (change.submittable === false) {
|
|
37
|
+
const reasons: string[] = []
|
|
38
|
+
|
|
39
|
+
// Check status
|
|
40
|
+
if (change.status !== 'NEW') {
|
|
41
|
+
reasons.push(`Change status is ${change.status} (must be NEW)`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for work in progress
|
|
45
|
+
if (change.work_in_progress) {
|
|
46
|
+
reasons.push('Change is marked as work-in-progress')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check labels for required approvals
|
|
50
|
+
if (change.labels) {
|
|
51
|
+
const codeReview = change.labels['Code-Review']
|
|
52
|
+
const verified = change.labels['Verified']
|
|
53
|
+
|
|
54
|
+
if (codeReview && !codeReview.approved) {
|
|
55
|
+
reasons.push('Missing Code-Review+2 approval')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (verified && !verified.approved) {
|
|
59
|
+
reasons.push('Missing Verified+1 approval')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If no specific reasons found but not submittable, add generic reason
|
|
64
|
+
if (reasons.length === 0) {
|
|
65
|
+
reasons.push('Change does not meet submit requirements')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.xml) {
|
|
69
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
70
|
+
console.log(`<submit_result>`)
|
|
71
|
+
console.log(` <status>error</status>`)
|
|
72
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
73
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
74
|
+
console.log(` <submittable>false</submittable>`)
|
|
75
|
+
console.log(` <reasons>`)
|
|
76
|
+
for (const reason of reasons) {
|
|
77
|
+
console.log(` <reason><![CDATA[${reason}]]></reason>`)
|
|
78
|
+
}
|
|
79
|
+
console.log(` </reasons>`)
|
|
80
|
+
console.log(`</submit_result>`)
|
|
81
|
+
} else {
|
|
82
|
+
console.error(`✗ Change ${change._number} cannot be submitted:`)
|
|
83
|
+
console.error(` ${change.subject}`)
|
|
84
|
+
console.error(``)
|
|
85
|
+
console.error(` Reasons:`)
|
|
86
|
+
for (const reason of reasons) {
|
|
87
|
+
console.error(` - ${reason}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Change is submittable, proceed with submission
|
|
94
|
+
const result = yield* gerritApi.submitChange(changeId)
|
|
95
|
+
|
|
96
|
+
if (options.xml) {
|
|
97
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
98
|
+
console.log(`<submit_result>`)
|
|
99
|
+
console.log(` <status>success</status>`)
|
|
100
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
101
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
102
|
+
console.log(` <submit_status>${result.status}</submit_status>`)
|
|
103
|
+
console.log(`</submit_result>`)
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`✓ Submitted change ${change._number}: ${change.subject}`)
|
|
106
|
+
console.log(` Status: ${result.status}`)
|
|
107
|
+
}
|
|
108
|
+
})
|