@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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. 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
+ })