@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,466 @@
|
|
|
1
|
+
import { Schema } from '@effect/schema'
|
|
2
|
+
import { Context, Effect, Layer } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
ChangeInfo,
|
|
5
|
+
CommentInfo,
|
|
6
|
+
MessageInfo,
|
|
7
|
+
type DiffOptions,
|
|
8
|
+
FileDiffContent,
|
|
9
|
+
FileInfo,
|
|
10
|
+
type GerritCredentials,
|
|
11
|
+
type ReviewInput,
|
|
12
|
+
RevisionInfo,
|
|
13
|
+
} from '@/schemas/gerrit'
|
|
14
|
+
import { filterMeaningfulMessages } from '@/utils/message-filters'
|
|
15
|
+
import { ConfigService } from '@/services/config'
|
|
16
|
+
|
|
17
|
+
export interface GerritApiServiceImpl {
|
|
18
|
+
readonly getChange: (changeId: string) => Effect.Effect<ChangeInfo, ApiError>
|
|
19
|
+
readonly listChanges: (query?: string) => Effect.Effect<readonly ChangeInfo[], ApiError>
|
|
20
|
+
readonly postReview: (changeId: string, review: ReviewInput) => Effect.Effect<void, ApiError>
|
|
21
|
+
readonly abandonChange: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
22
|
+
readonly testConnection: Effect.Effect<boolean, ApiError>
|
|
23
|
+
readonly getRevision: (
|
|
24
|
+
changeId: string,
|
|
25
|
+
revisionId?: string,
|
|
26
|
+
) => Effect.Effect<RevisionInfo, ApiError>
|
|
27
|
+
readonly getFiles: (
|
|
28
|
+
changeId: string,
|
|
29
|
+
revisionId?: string,
|
|
30
|
+
) => Effect.Effect<Record<string, FileInfo>, ApiError>
|
|
31
|
+
readonly getFileDiff: (
|
|
32
|
+
changeId: string,
|
|
33
|
+
filePath: string,
|
|
34
|
+
revisionId?: string,
|
|
35
|
+
base?: string,
|
|
36
|
+
) => Effect.Effect<FileDiffContent, ApiError>
|
|
37
|
+
readonly getFileContent: (
|
|
38
|
+
changeId: string,
|
|
39
|
+
filePath: string,
|
|
40
|
+
revisionId?: string,
|
|
41
|
+
) => Effect.Effect<string, ApiError>
|
|
42
|
+
readonly getPatch: (changeId: string, revisionId?: string) => Effect.Effect<string, ApiError>
|
|
43
|
+
readonly getDiff: (
|
|
44
|
+
changeId: string,
|
|
45
|
+
options?: DiffOptions,
|
|
46
|
+
) => Effect.Effect<string | string[] | Record<string, unknown> | FileDiffContent, ApiError>
|
|
47
|
+
readonly getComments: (
|
|
48
|
+
changeId: string,
|
|
49
|
+
revisionId?: string,
|
|
50
|
+
) => Effect.Effect<Record<string, readonly CommentInfo[]>, ApiError>
|
|
51
|
+
readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class GerritApiService extends Context.Tag('GerritApiService')<
|
|
55
|
+
GerritApiService,
|
|
56
|
+
GerritApiServiceImpl
|
|
57
|
+
>() {}
|
|
58
|
+
|
|
59
|
+
export class ApiError extends Schema.TaggedError<ApiError>()('ApiError', {
|
|
60
|
+
message: Schema.String,
|
|
61
|
+
status: Schema.optional(Schema.Number),
|
|
62
|
+
} as const) {}
|
|
63
|
+
|
|
64
|
+
const createAuthHeader = (credentials: GerritCredentials): string => {
|
|
65
|
+
const auth = btoa(`${credentials.username}:${credentials.password}`)
|
|
66
|
+
return `Basic ${auth}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const makeRequest = <T = unknown>(
|
|
70
|
+
url: string,
|
|
71
|
+
authHeader: string,
|
|
72
|
+
method: 'GET' | 'POST' = 'GET',
|
|
73
|
+
body?: unknown,
|
|
74
|
+
schema?: Schema.Schema<T>,
|
|
75
|
+
): Effect.Effect<T, ApiError> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
Authorization: authHeader,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (body) {
|
|
82
|
+
headers['Content-Type'] = 'application/json'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const response = yield* Effect.tryPromise({
|
|
86
|
+
try: () =>
|
|
87
|
+
fetch(url, {
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
...(method !== 'GET' && body ? { body: JSON.stringify(body) } : {}),
|
|
91
|
+
}),
|
|
92
|
+
catch: () => new ApiError({ message: 'Request failed - network or authentication error' }),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const errorText = yield* Effect.tryPromise({
|
|
97
|
+
try: () => response.text(),
|
|
98
|
+
catch: () => 'Unknown error',
|
|
99
|
+
}).pipe(Effect.orElseSucceed(() => 'Unknown error'))
|
|
100
|
+
yield* Effect.fail(
|
|
101
|
+
new ApiError({
|
|
102
|
+
message: errorText,
|
|
103
|
+
status: response.status,
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = yield* Effect.tryPromise({
|
|
109
|
+
try: () => response.text(),
|
|
110
|
+
catch: () => new ApiError({ message: 'Failed to read response data' }),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Gerrit returns JSON with )]}' prefix for security
|
|
114
|
+
const cleanJson = text.replace(/^\)\]\}'\n?/, '')
|
|
115
|
+
|
|
116
|
+
if (!cleanJson.trim()) {
|
|
117
|
+
return null as unknown as T
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parsed = yield* Effect.try({
|
|
121
|
+
try: () => JSON.parse(cleanJson),
|
|
122
|
+
catch: () => new ApiError({ message: 'Failed to parse response - invalid JSON format' }),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (schema) {
|
|
126
|
+
return yield* Schema.decodeUnknown(schema)(parsed).pipe(
|
|
127
|
+
Effect.mapError(() => new ApiError({ message: 'Invalid response format from server' })),
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return parsed as unknown as T
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigService> =
|
|
135
|
+
Layer.effect(
|
|
136
|
+
GerritApiService,
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const configService = yield* ConfigService
|
|
139
|
+
|
|
140
|
+
const getCredentialsAndAuth = Effect.gen(function* () {
|
|
141
|
+
const credentials = yield* configService.getCredentials.pipe(
|
|
142
|
+
Effect.mapError(() => new ApiError({ message: 'Failed to get credentials' })),
|
|
143
|
+
)
|
|
144
|
+
// Ensure host doesn't have trailing slash
|
|
145
|
+
const normalizedCredentials = {
|
|
146
|
+
...credentials,
|
|
147
|
+
host: credentials.host.replace(/\/$/, ''),
|
|
148
|
+
}
|
|
149
|
+
const authHeader = createAuthHeader(normalizedCredentials)
|
|
150
|
+
return { credentials: normalizedCredentials, authHeader }
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const getChange = (changeId: string) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
156
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}`
|
|
157
|
+
return yield* makeRequest(url, authHeader, 'GET', undefined, ChangeInfo)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const listChanges = (query = 'is:open') =>
|
|
161
|
+
Effect.gen(function* () {
|
|
162
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
163
|
+
const encodedQuery = encodeURIComponent(query)
|
|
164
|
+
// Add additional options to get detailed information
|
|
165
|
+
const url = `${credentials.host}/a/changes/?q=${encodedQuery}&o=LABELS&o=DETAILED_LABELS&o=DETAILED_ACCOUNTS&o=SUBMITTABLE`
|
|
166
|
+
return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(ChangeInfo))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const postReview = (changeId: string, review: ReviewInput) =>
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
172
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/current/review`
|
|
173
|
+
yield* makeRequest(url, authHeader, 'POST', review)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const abandonChange = (changeId: string, message?: string) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
179
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/abandon`
|
|
180
|
+
const body = message ? { message } : {}
|
|
181
|
+
yield* makeRequest(url, authHeader, 'POST', body)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const testConnection = Effect.gen(function* () {
|
|
185
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
186
|
+
const url = `${credentials.host}/a/accounts/self`
|
|
187
|
+
yield* makeRequest(url, authHeader)
|
|
188
|
+
return true
|
|
189
|
+
}).pipe(
|
|
190
|
+
Effect.catchAll((error) => {
|
|
191
|
+
// Log the actual error for debugging
|
|
192
|
+
if (process.env.DEBUG) {
|
|
193
|
+
console.error('Connection error:', error)
|
|
194
|
+
}
|
|
195
|
+
return Effect.succeed(false)
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const getRevision = (changeId: string, revisionId = 'current') =>
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
202
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}`
|
|
203
|
+
return yield* makeRequest(url, authHeader, 'GET', undefined, RevisionInfo)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const getFiles = (changeId: string, revisionId = 'current') =>
|
|
207
|
+
Effect.gen(function* () {
|
|
208
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
209
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files`
|
|
210
|
+
return yield* makeRequest(
|
|
211
|
+
url,
|
|
212
|
+
authHeader,
|
|
213
|
+
'GET',
|
|
214
|
+
undefined,
|
|
215
|
+
Schema.Record({ key: Schema.String, value: FileInfo }),
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const getFileDiff = (
|
|
220
|
+
changeId: string,
|
|
221
|
+
filePath: string,
|
|
222
|
+
revisionId = 'current',
|
|
223
|
+
base?: string,
|
|
224
|
+
) =>
|
|
225
|
+
Effect.gen(function* () {
|
|
226
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
227
|
+
let url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/diff`
|
|
228
|
+
if (base) {
|
|
229
|
+
url += `?base=${encodeURIComponent(base)}`
|
|
230
|
+
}
|
|
231
|
+
return yield* makeRequest(url, authHeader, 'GET', undefined, FileDiffContent)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const getFileContent = (changeId: string, filePath: string, revisionId = 'current') =>
|
|
235
|
+
Effect.gen(function* () {
|
|
236
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
237
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content`
|
|
238
|
+
|
|
239
|
+
const response = yield* Effect.tryPromise({
|
|
240
|
+
try: () =>
|
|
241
|
+
fetch(url, {
|
|
242
|
+
method: 'GET',
|
|
243
|
+
headers: { Authorization: authHeader },
|
|
244
|
+
}),
|
|
245
|
+
catch: () =>
|
|
246
|
+
new ApiError({ message: 'Request failed - network or authentication error' }),
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
const errorText = yield* Effect.tryPromise({
|
|
251
|
+
try: () => response.text(),
|
|
252
|
+
catch: () => 'Unknown error',
|
|
253
|
+
}).pipe(Effect.orElseSucceed(() => 'Unknown error'))
|
|
254
|
+
|
|
255
|
+
yield* Effect.fail(
|
|
256
|
+
new ApiError({
|
|
257
|
+
message: errorText,
|
|
258
|
+
status: response.status,
|
|
259
|
+
}),
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const base64Content = yield* Effect.tryPromise({
|
|
264
|
+
try: () => response.text(),
|
|
265
|
+
catch: () => new ApiError({ message: 'Failed to read response data' }),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return yield* Effect.try({
|
|
269
|
+
try: () => atob(base64Content),
|
|
270
|
+
catch: () => new ApiError({ message: 'Failed to decode file content' }),
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const getPatch = (changeId: string, revisionId = 'current') =>
|
|
275
|
+
Effect.gen(function* () {
|
|
276
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
277
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/patch`
|
|
278
|
+
|
|
279
|
+
const response = yield* Effect.tryPromise({
|
|
280
|
+
try: () =>
|
|
281
|
+
fetch(url, {
|
|
282
|
+
method: 'GET',
|
|
283
|
+
headers: { Authorization: authHeader },
|
|
284
|
+
}),
|
|
285
|
+
catch: () =>
|
|
286
|
+
new ApiError({ message: 'Request failed - network or authentication error' }),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
const errorText = yield* Effect.tryPromise({
|
|
291
|
+
try: () => response.text(),
|
|
292
|
+
catch: () => 'Unknown error',
|
|
293
|
+
}).pipe(Effect.orElseSucceed(() => 'Unknown error'))
|
|
294
|
+
|
|
295
|
+
yield* Effect.fail(
|
|
296
|
+
new ApiError({
|
|
297
|
+
message: errorText,
|
|
298
|
+
status: response.status,
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const base64Patch = yield* Effect.tryPromise({
|
|
304
|
+
try: () => response.text(),
|
|
305
|
+
catch: () => new ApiError({ message: 'Failed to read response data' }),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
return yield* Effect.try({
|
|
309
|
+
try: () => atob(base64Patch),
|
|
310
|
+
catch: () => new ApiError({ message: 'Failed to decode patch data' }),
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const getDiff = (changeId: string, options: DiffOptions = {}) =>
|
|
315
|
+
Effect.gen(function* () {
|
|
316
|
+
const format = options.format || 'unified'
|
|
317
|
+
const revisionId = options.patchset ? `${options.patchset}` : 'current'
|
|
318
|
+
|
|
319
|
+
if (format === 'files') {
|
|
320
|
+
const files = yield* getFiles(changeId, revisionId)
|
|
321
|
+
return Object.keys(files)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (options.file) {
|
|
325
|
+
if (format === 'json') {
|
|
326
|
+
const diff = yield* getFileDiff(
|
|
327
|
+
changeId,
|
|
328
|
+
options.file,
|
|
329
|
+
revisionId,
|
|
330
|
+
options.base ? `${options.base}` : undefined,
|
|
331
|
+
)
|
|
332
|
+
return diff
|
|
333
|
+
} else {
|
|
334
|
+
const diff = yield* getFileDiff(
|
|
335
|
+
changeId,
|
|
336
|
+
options.file,
|
|
337
|
+
revisionId,
|
|
338
|
+
options.base ? `${options.base}` : undefined,
|
|
339
|
+
)
|
|
340
|
+
return convertToUnifiedDiff(diff, options.file)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options.fullFiles) {
|
|
345
|
+
const files = yield* getFiles(changeId, revisionId)
|
|
346
|
+
const result: Record<string, string> = {}
|
|
347
|
+
|
|
348
|
+
for (const [filePath, _fileInfo] of Object.entries(files)) {
|
|
349
|
+
if (filePath === '/COMMIT_MSG' || filePath === '/MERGE_LIST') continue
|
|
350
|
+
|
|
351
|
+
const content = yield* getFileContent(changeId, filePath, revisionId).pipe(
|
|
352
|
+
Effect.catchAll(() => Effect.succeed('Binary file or permission denied')),
|
|
353
|
+
)
|
|
354
|
+
result[filePath] = content
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return format === 'json'
|
|
358
|
+
? result
|
|
359
|
+
: Object.entries(result)
|
|
360
|
+
.map(([path, content]) => `=== ${path} ===\n${content}\n`)
|
|
361
|
+
.join('\n')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (format === 'json') {
|
|
365
|
+
const files = yield* getFiles(changeId, revisionId)
|
|
366
|
+
return files
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return yield* getPatch(changeId, revisionId)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const convertToUnifiedDiff = (diff: FileDiffContent, filePath: string): string => {
|
|
373
|
+
const lines: string[] = []
|
|
374
|
+
|
|
375
|
+
if (diff.diff_header) {
|
|
376
|
+
lines.push(...diff.diff_header)
|
|
377
|
+
} else {
|
|
378
|
+
lines.push(`--- a/${filePath}`)
|
|
379
|
+
lines.push(`+++ b/${filePath}`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let _oldLineNum = 1
|
|
383
|
+
let _newLineNum = 1
|
|
384
|
+
|
|
385
|
+
for (const section of diff.content) {
|
|
386
|
+
if (section.ab) {
|
|
387
|
+
for (const line of section.ab) {
|
|
388
|
+
lines.push(` ${line}`)
|
|
389
|
+
_oldLineNum++
|
|
390
|
+
_newLineNum++
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (section.a) {
|
|
395
|
+
for (const line of section.a) {
|
|
396
|
+
lines.push(`-${line}`)
|
|
397
|
+
_oldLineNum++
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (section.b) {
|
|
402
|
+
for (const line of section.b) {
|
|
403
|
+
lines.push(`+${line}`)
|
|
404
|
+
_newLineNum++
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (section.skip) {
|
|
409
|
+
_oldLineNum += section.skip
|
|
410
|
+
_newLineNum += section.skip
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return lines.join('\n')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const getComments = (changeId: string, revisionId = 'current') =>
|
|
418
|
+
Effect.gen(function* () {
|
|
419
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
420
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/comments`
|
|
421
|
+
return yield* makeRequest(
|
|
422
|
+
url,
|
|
423
|
+
authHeader,
|
|
424
|
+
'GET',
|
|
425
|
+
undefined,
|
|
426
|
+
Schema.Record({ key: Schema.String, value: Schema.Array(CommentInfo) }),
|
|
427
|
+
)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const getMessages = (changeId: string) =>
|
|
431
|
+
Effect.gen(function* () {
|
|
432
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
433
|
+
const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}?o=MESSAGES`
|
|
434
|
+
const response = yield* makeRequest(url, authHeader, 'GET')
|
|
435
|
+
|
|
436
|
+
// Extract messages from the change response with runtime validation
|
|
437
|
+
const changeResponse = yield* Schema.decodeUnknown(
|
|
438
|
+
Schema.Struct({
|
|
439
|
+
messages: Schema.optional(Schema.Array(MessageInfo)),
|
|
440
|
+
}),
|
|
441
|
+
)(response).pipe(
|
|
442
|
+
Effect.mapError(
|
|
443
|
+
() => new ApiError({ message: 'Invalid messages response format from server' }),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return changeResponse.messages || []
|
|
448
|
+
}).pipe(Effect.map(filterMeaningfulMessages))
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
getChange,
|
|
452
|
+
listChanges,
|
|
453
|
+
postReview,
|
|
454
|
+
abandonChange,
|
|
455
|
+
testConnection,
|
|
456
|
+
getRevision,
|
|
457
|
+
getFiles,
|
|
458
|
+
getFileDiff,
|
|
459
|
+
getFileContent,
|
|
460
|
+
getPatch,
|
|
461
|
+
getDiff,
|
|
462
|
+
getComments,
|
|
463
|
+
getMessages,
|
|
464
|
+
}
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
|
|
4
|
+
interface AbandonOptions {
|
|
5
|
+
message?: string
|
|
6
|
+
xml?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const abandonCommand = (
|
|
10
|
+
changeId?: string,
|
|
11
|
+
options: AbandonOptions = {},
|
|
12
|
+
): Effect.Effect<void, ApiError, GerritApiService> =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const gerritApi = yield* GerritApiService
|
|
15
|
+
|
|
16
|
+
if (!changeId) {
|
|
17
|
+
console.error('✗ Change ID is required')
|
|
18
|
+
console.error(' Usage: ger abandon <change-id>')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// First get the change details to show what we're abandoning
|
|
24
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
25
|
+
|
|
26
|
+
// Perform the abandon
|
|
27
|
+
yield* gerritApi.abandonChange(changeId, options.message)
|
|
28
|
+
|
|
29
|
+
if (options.xml) {
|
|
30
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
31
|
+
console.log(`<abandon_result>`)
|
|
32
|
+
console.log(` <status>success</status>`)
|
|
33
|
+
console.log(` <change_number>${change._number}</change_number>`)
|
|
34
|
+
console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
|
|
35
|
+
if (options.message) {
|
|
36
|
+
console.log(` <message><![CDATA[${options.message}]]></message>`)
|
|
37
|
+
}
|
|
38
|
+
console.log(`</abandon_result>`)
|
|
39
|
+
} else {
|
|
40
|
+
console.log(`✓ Abandoned change ${change._number}: ${change.subject}`)
|
|
41
|
+
if (options.message) {
|
|
42
|
+
console.log(` Message: ${options.message}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// If we can't get change details, still try to abandon with just the ID
|
|
47
|
+
yield* gerritApi.abandonChange(changeId, options.message)
|
|
48
|
+
|
|
49
|
+
if (options.xml) {
|
|
50
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
51
|
+
console.log(`<abandon_result>`)
|
|
52
|
+
console.log(` <status>success</status>`)
|
|
53
|
+
console.log(` <change_id>${changeId}</change_id>`)
|
|
54
|
+
if (options.message) {
|
|
55
|
+
console.log(` <message><![CDATA[${options.message}]]></message>`)
|
|
56
|
+
}
|
|
57
|
+
console.log(`</abandon_result>`)
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`✓ Abandoned change ${changeId}`)
|
|
60
|
+
if (options.message) {
|
|
61
|
+
console.log(` Message: ${options.message}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|