@aaronshaf/ger 2.0.1 → 2.0.3

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/src/api/gerrit.ts CHANGED
@@ -90,26 +90,25 @@ export interface GerritApiServiceImpl {
90
90
  accountId: string,
91
91
  options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
92
92
  ) => Effect.Effect<void, ApiError>
93
+ readonly getTopic: (changeId: string) => Effect.Effect<string | null, ApiError>
94
+ readonly setTopic: (changeId: string, topic: string) => Effect.Effect<string, ApiError>
95
+ readonly deleteTopic: (changeId: string) => Effect.Effect<void, ApiError>
93
96
  }
94
97
 
95
- // Export both the tag value and the type for use in Effect requirements
96
98
  export const GerritApiService: Context.Tag<GerritApiServiceImpl, GerritApiServiceImpl> =
97
99
  Context.GenericTag<GerritApiServiceImpl>('GerritApiService')
98
100
  export type GerritApiService = Context.Tag.Identifier<typeof GerritApiService>
99
101
 
100
- // Export ApiError fields interface explicitly
101
102
  export interface ApiErrorFields {
102
103
  readonly message: string
103
104
  readonly status?: number
104
105
  }
105
106
 
106
- // Define error schema (not exported, so type can be implicit)
107
107
  const ApiErrorSchema = Schema.TaggedError<ApiErrorFields>()('ApiError', {
108
108
  message: Schema.String,
109
109
  status: Schema.optional(Schema.Number),
110
110
  } as const) as unknown
111
111
 
112
- // Export the error class with explicit constructor signature for isolatedDeclarations
113
112
  export class ApiError
114
113
  extends (ApiErrorSchema as new (
115
114
  args: ApiErrorFields,
@@ -127,7 +126,7 @@ const createAuthHeader = (credentials: GerritCredentials): string => {
127
126
  const makeRequest = <T = unknown>(
128
127
  url: string,
129
128
  authHeader: string,
130
- method: 'GET' | 'POST' = 'GET',
129
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
131
130
  body?: unknown,
132
131
  schema?: Schema.Schema<T>,
133
132
  ): Effect.Effect<T, ApiError> =>
@@ -155,12 +154,7 @@ const makeRequest = <T = unknown>(
155
154
  try: () => response.text(),
156
155
  catch: () => 'Unknown error',
157
156
  }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
158
- yield* Effect.fail(
159
- new ApiError({
160
- message: errorText,
161
- status: response.status,
162
- }),
163
- )
157
+ yield* Effect.fail(new ApiError({ message: errorText, status: response.status }))
164
158
  }
165
159
 
166
160
  const text = yield* Effect.tryPromise({
@@ -168,13 +162,8 @@ const makeRequest = <T = unknown>(
168
162
  catch: () => new ApiError({ message: 'Failed to read response data' }),
169
163
  })
170
164
 
171
- // Gerrit returns JSON with )]}' prefix for security
172
165
  const cleanJson = text.replace(/^\)\]\}'\n?/, '')
173
-
174
- if (!cleanJson.trim()) {
175
- // Empty response - return empty object for endpoints that expect void
176
- return {} as T
177
- }
166
+ if (!cleanJson.trim()) return {} as T
178
167
 
179
168
  const parsed = yield* Effect.try({
180
169
  try: () => JSON.parse(cleanJson),
@@ -186,8 +175,6 @@ const makeRequest = <T = unknown>(
186
175
  Effect.mapError(() => new ApiError({ message: 'Invalid response format from server' })),
187
176
  )
188
177
  }
189
-
190
- // When no schema is provided, the caller expects void or doesn't care about the response
191
178
  return parsed
192
179
  })
193
180
 
@@ -201,16 +188,13 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
201
188
  const credentials = yield* configService.getCredentials.pipe(
202
189
  Effect.mapError(() => new ApiError({ message: 'Failed to get credentials' })),
203
190
  )
204
- // Ensure host doesn't have trailing slash
205
- const normalizedCredentials = {
206
- ...credentials,
207
- host: credentials.host.replace(/\/$/, ''),
191
+ const normalizedCredentials = { ...credentials, host: credentials.host.replace(/\/$/, '') }
192
+ return {
193
+ credentials: normalizedCredentials,
194
+ authHeader: createAuthHeader(normalizedCredentials),
208
195
  }
209
- const authHeader = createAuthHeader(normalizedCredentials)
210
- return { credentials: normalizedCredentials, authHeader }
211
196
  })
212
197
 
213
- // Helper to normalize and validate change identifier
214
198
  const normalizeAndValidate = (changeId: string): Effect.Effect<string, ApiError> =>
215
199
  Effect.try({
216
200
  try: () => normalizeChangeIdentifier(changeId),
@@ -231,9 +215,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
231
215
  const listChanges = (query = 'is:open') =>
232
216
  Effect.gen(function* () {
233
217
  const { credentials, authHeader } = yield* getCredentialsAndAuth
234
- const encodedQuery = encodeURIComponent(query)
235
- // Add additional options to get detailed information
236
- const url = `${credentials.host}/a/changes/?q=${encodedQuery}&o=LABELS&o=DETAILED_LABELS&o=DETAILED_ACCOUNTS&o=SUBMITTABLE`
218
+ const url = `${credentials.host}/a/changes/?q=${encodeURIComponent(query)}&o=LABELS&o=DETAILED_LABELS&o=DETAILED_ACCOUNTS&o=SUBMITTABLE`
237
219
  return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(ChangeInfo))
238
220
  })
239
221
 
@@ -241,10 +223,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
241
223
  Effect.gen(function* () {
242
224
  const { credentials, authHeader } = yield* getCredentialsAndAuth
243
225
  let url = `${credentials.host}/a/projects/`
244
- if (options?.pattern) {
245
- url += `?p=${encodeURIComponent(options.pattern)}`
246
- }
247
- // Gerrit returns projects as a Record, need to convert to array
226
+ if (options?.pattern) url += `?p=${encodeURIComponent(options.pattern)}`
248
227
  const projectsRecord = yield* makeRequest(
249
228
  url,
250
229
  authHeader,
@@ -252,7 +231,6 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
252
231
  undefined,
253
232
  Schema.Record({ key: Schema.String, value: ProjectInfo }),
254
233
  )
255
- // Convert Record to Array and sort alphabetically by name
256
234
  return Object.values(projectsRecord).sort((a, b) => a.name.localeCompare(b.name))
257
235
  })
258
236
 
@@ -306,7 +284,6 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
306
284
  return true
307
285
  }).pipe(
308
286
  Effect.catchAll((error) => {
309
- // Log the actual error for debugging
310
287
  if (process.env.DEBUG) {
311
288
  console.error('Connection error:', error)
312
289
  }
@@ -373,15 +350,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
373
350
  try: () => response.text(),
374
351
  catch: () => 'Unknown error',
375
352
  }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
376
-
377
- yield* Effect.fail(
378
- new ApiError({
379
- message: errorText,
380
- status: response.status,
381
- }),
382
- )
353
+ yield* Effect.fail(new ApiError({ message: errorText, status: response.status }))
383
354
  }
384
-
385
355
  const base64Content = yield* Effect.tryPromise({
386
356
  try: () => response.text(),
387
357
  catch: () => new ApiError({ message: 'Failed to read response data' }),
@@ -414,15 +384,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
414
384
  try: () => response.text(),
415
385
  catch: () => 'Unknown error',
416
386
  }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
417
-
418
- yield* Effect.fail(
419
- new ApiError({
420
- message: errorText,
421
- status: response.status,
422
- }),
423
- )
387
+ yield* Effect.fail(new ApiError({ message: errorText, status: response.status }))
424
388
  }
425
-
426
389
  const base64Patch = yield* Effect.tryPromise({
427
390
  try: () => response.text(),
428
391
  catch: () => new ApiError({ message: 'Failed to read response data' }),
@@ -546,7 +509,6 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
546
509
  const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=MESSAGES`
547
510
  const response = yield* makeRequest(url, authHeader, 'GET')
548
511
 
549
- // Extract messages from the change response with runtime validation
550
512
  const changeResponse = yield* Schema.decodeUnknown(
551
513
  Schema.Struct({
552
514
  messages: Schema.optional(Schema.Array(MessageInfo)),
@@ -616,7 +578,6 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
616
578
  url += `?${params.join('&')}`
617
579
  }
618
580
 
619
- // Gerrit returns groups as a Record, need to convert to array
620
581
  const groupsRecord = yield* makeRequest(
621
582
  url,
622
583
  authHeader,
@@ -624,12 +585,9 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
624
585
  undefined,
625
586
  Schema.Record({ key: Schema.String, value: GroupInfo }),
626
587
  )
627
- // Convert Record to Array and sort alphabetically by name
628
- return Object.values(groupsRecord).sort((a, b) => {
629
- const aName = a.name || a.id
630
- const bName = b.name || b.id
631
- return aName.localeCompare(bName)
632
- })
588
+ return Object.values(groupsRecord).sort((a, b) =>
589
+ (a.name || a.id).localeCompare(b.name || b.id),
590
+ )
633
591
  })
634
592
 
635
593
  const getGroup = (groupId: string) =>
@@ -661,12 +619,54 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
661
619
  Effect.gen(function* () {
662
620
  const { credentials, authHeader } = yield* getCredentialsAndAuth
663
621
  const normalized = yield* normalizeAndValidate(changeId)
664
- // Use POST to /delete endpoint to support request body with notify option
665
622
  const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers/${encodeURIComponent(accountId)}/delete`
666
623
  const body = options?.notify ? { notify: options.notify } : {}
667
624
  yield* makeRequest(url, authHeader, 'POST', body)
668
625
  })
669
626
 
627
+ const getTopicUrl = (host: string, changeId: string): string =>
628
+ `${host}/a/changes/${encodeURIComponent(changeId)}/topic`
629
+
630
+ const getTopic = (changeId: string) =>
631
+ Effect.gen(function* () {
632
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
633
+ const normalized = yield* normalizeAndValidate(changeId)
634
+ return yield* makeRequest(
635
+ getTopicUrl(credentials.host, normalized),
636
+ authHeader,
637
+ 'GET',
638
+ undefined,
639
+ Schema.String,
640
+ ).pipe(
641
+ Effect.map((t) => t.replace(/^"|"$/g, '') || null),
642
+ Effect.catchIf(
643
+ (e) => e instanceof ApiError && e.status === 404,
644
+ () => Effect.succeed(null),
645
+ ),
646
+ )
647
+ })
648
+
649
+ const setTopic = (changeId: string, topic: string) =>
650
+ Effect.gen(function* () {
651
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
652
+ const normalized = yield* normalizeAndValidate(changeId)
653
+ const result = yield* makeRequest(
654
+ getTopicUrl(credentials.host, normalized),
655
+ authHeader,
656
+ 'PUT',
657
+ { topic },
658
+ Schema.String,
659
+ )
660
+ return result.replace(/^"|"$/g, '')
661
+ })
662
+
663
+ const deleteTopic = (changeId: string) =>
664
+ Effect.gen(function* () {
665
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
666
+ const normalized = yield* normalizeAndValidate(changeId)
667
+ yield* makeRequest(getTopicUrl(credentials.host, normalized), authHeader, 'DELETE')
668
+ })
669
+
670
670
  return {
671
671
  getChange,
672
672
  listChanges,
@@ -691,6 +691,9 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
691
691
  getGroupDetail,
692
692
  getGroupMembers,
693
693
  removeReviewer,
694
+ getTopic,
695
+ setTopic,
696
+ deleteTopic,
694
697
  }
695
698
  }),
696
699
  )
@@ -3,6 +3,24 @@ import { Effect, pipe } from 'effect'
3
3
  import { type ApiError, GerritApiService } from '@/api/gerrit'
4
4
  import type { ChangeInfo, ReviewInput } from '@/schemas/gerrit'
5
5
 
6
+ export const COMMENT_HELP_TEXT = `
7
+ Examples:
8
+ # Post a general comment on a change
9
+ $ ger comment 12345 -m "Looks good to me!"
10
+
11
+ # Post a comment using piped input
12
+ $ echo "This is a comment from stdin!" | ger comment 12345
13
+
14
+ # Post a line-specific comment
15
+ $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
16
+
17
+ # Post multiple comments using batch mode
18
+ $ echo '{"message": "Review complete", "comments": [
19
+ {"file": "src/main.js", "line": 10, "message": "Good refactor"}
20
+ ]}' | ger comment 12345 --batch
21
+
22
+ Note: Line numbers refer to the NEW version of the file, not diff line numbers.`
23
+
6
24
  interface CommentOptions {
7
25
  message?: string
8
26
  xml?: boolean
@@ -0,0 +1,59 @@
1
+ import { Effect, Console } from 'effect'
2
+ import chalk from 'chalk'
3
+ import {
4
+ CommitHookService,
5
+ type CommitHookServiceImpl,
6
+ type HookInstallError,
7
+ type NotGitRepoError,
8
+ } from '@/services/commit-hook'
9
+ import { type ConfigError, type ConfigServiceImpl } from '@/services/config'
10
+
11
+ export interface InstallHookOptions {
12
+ force?: boolean
13
+ xml?: boolean
14
+ }
15
+
16
+ export type InstallHookErrors = ConfigError | HookInstallError | NotGitRepoError
17
+
18
+ export const installHookCommand = (
19
+ options: InstallHookOptions,
20
+ ): Effect.Effect<void, InstallHookErrors, CommitHookServiceImpl | ConfigServiceImpl> =>
21
+ Effect.gen(function* () {
22
+ const commitHookService = yield* CommitHookService
23
+
24
+ // Check if hook already exists using service method
25
+ const hookExists = yield* commitHookService.hasHook()
26
+
27
+ if (hookExists && !options.force) {
28
+ if (options.xml) {
29
+ yield* Console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
+ yield* Console.log('<install_hook_result>')
31
+ yield* Console.log(' <status>skipped</status>')
32
+ yield* Console.log(' <message><![CDATA[commit-msg hook already installed]]></message>')
33
+ yield* Console.log(' <hint><![CDATA[Use --force to overwrite]]></hint>')
34
+ yield* Console.log('</install_hook_result>')
35
+ } else {
36
+ yield* Console.log(chalk.yellow('commit-msg hook already installed'))
37
+ yield* Console.log(chalk.dim('Use --force to overwrite'))
38
+ }
39
+ return
40
+ }
41
+
42
+ if (hookExists && options.force) {
43
+ if (!options.xml) {
44
+ yield* Console.log(chalk.yellow('Overwriting existing commit-msg hook...'))
45
+ }
46
+ }
47
+
48
+ // Install the hook (service logs progress messages in non-XML mode)
49
+ yield* commitHookService.installHook()
50
+
51
+ // Only output XML here - service already logs success message for non-XML mode
52
+ if (options.xml) {
53
+ yield* Console.log('<?xml version="1.0" encoding="UTF-8"?>')
54
+ yield* Console.log('<install_hook_result>')
55
+ yield* Console.log(' <status>success</status>')
56
+ yield* Console.log(' <message><![CDATA[commit-msg hook installed successfully]]></message>')
57
+ yield* Console.log('</install_hook_result>')
58
+ }
59
+ })
@@ -7,7 +7,6 @@ import { formatDiffPretty } from '@/utils/diff-formatters'
7
7
  import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
8
  import { formatDate } from '@/utils/formatters'
9
9
  import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
- import { writeFileSync } from 'node:fs'
11
10
 
12
11
  export const SHOW_HELP_TEXT = `
13
12
  Examples:
@@ -34,6 +33,13 @@ interface ShowOptions {
34
33
  json?: boolean
35
34
  }
36
35
 
36
+ interface ReviewerIdentity {
37
+ accountId?: number
38
+ name?: string
39
+ email?: string
40
+ username?: string
41
+ }
42
+
37
43
  interface ChangeDetails {
38
44
  id: string
39
45
  number: number
@@ -48,6 +54,25 @@ interface ChangeDetails {
48
54
  created?: string
49
55
  updated?: string
50
56
  commitMessage: string
57
+ topic?: string
58
+ reviewers: ReviewerIdentity[]
59
+ ccs: ReviewerIdentity[]
60
+ }
61
+
62
+ const formatReviewerLabel = (reviewer: ReviewerIdentity): string => {
63
+ const preferredIdentity = reviewer.name || reviewer.email || reviewer.username
64
+ if (!preferredIdentity) {
65
+ if (reviewer.accountId !== undefined) {
66
+ return `Account ${reviewer.accountId}`
67
+ }
68
+ return 'Unknown Reviewer'
69
+ }
70
+
71
+ if (reviewer.email && reviewer.name && reviewer.name !== reviewer.email) {
72
+ return `${reviewer.name} <${reviewer.email}>`
73
+ }
74
+
75
+ return preferredIdentity
51
76
  }
52
77
 
53
78
  const getChangeDetails = (
@@ -57,6 +82,21 @@ const getChangeDetails = (
57
82
  const gerritApi = yield* GerritApiService
58
83
  const change = yield* gerritApi.getChange(changeId)
59
84
 
85
+ let reviewerMap = change.reviewers
86
+ const shouldFetchReviewerFallback =
87
+ reviewerMap === undefined ||
88
+ (reviewerMap.REVIEWER === undefined && reviewerMap.CC === undefined)
89
+
90
+ if (shouldFetchReviewerFallback) {
91
+ const detailedChanges = yield* gerritApi
92
+ .listChanges(`change:${change._number}`)
93
+ .pipe(Effect.catchAll(() => Effect.succeed([])))
94
+ const detailedChange =
95
+ detailedChanges.find((candidate) => candidate._number === change._number) ||
96
+ detailedChanges[0]
97
+ reviewerMap = detailedChange?.reviewers
98
+ }
99
+
60
100
  return {
61
101
  id: change.change_id,
62
102
  number: change._number,
@@ -71,6 +111,19 @@ const getChangeDetails = (
71
111
  created: change.created,
72
112
  updated: change.updated,
73
113
  commitMessage: change.subject, // For now, using subject as commit message
114
+ topic: change.topic,
115
+ reviewers: (reviewerMap?.REVIEWER ?? []).map((reviewer) => ({
116
+ accountId: reviewer._account_id,
117
+ name: reviewer.name,
118
+ email: reviewer.email,
119
+ username: reviewer.username,
120
+ })),
121
+ ccs: (reviewerMap?.CC ?? []).map((cc) => ({
122
+ accountId: cc._account_id,
123
+ name: cc.name,
124
+ email: cc.email,
125
+ username: cc.username,
126
+ })),
74
127
  }
75
128
  })
76
129
 
@@ -142,6 +195,9 @@ const formatShowPretty = (
142
195
  console.log(` Project: ${changeDetails.project}`)
143
196
  console.log(` Branch: ${changeDetails.branch}`)
144
197
  console.log(` Status: ${changeDetails.status}`)
198
+ if (changeDetails.topic) {
199
+ console.log(` Topic: ${changeDetails.topic}`)
200
+ }
145
201
  console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
146
202
  console.log(
147
203
  ` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
@@ -149,6 +205,14 @@ const formatShowPretty = (
149
205
  console.log(
150
206
  ` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`,
151
207
  )
208
+ if (changeDetails.reviewers.length > 0) {
209
+ console.log(
210
+ ` Reviewers: ${changeDetails.reviewers.map((reviewer) => formatReviewerLabel(reviewer)).join(', ')}`,
211
+ )
212
+ }
213
+ if (changeDetails.ccs.length > 0) {
214
+ console.log(` CCs: ${changeDetails.ccs.map((cc) => formatReviewerLabel(cc)).join(', ')}`)
215
+ }
152
216
  console.log(` Change-Id: ${changeDetails.id}`)
153
217
  console.log()
154
218
 
@@ -220,7 +284,24 @@ const formatShowJson = async (
220
284
  status: changeDetails.status,
221
285
  project: changeDetails.project,
222
286
  branch: changeDetails.branch,
287
+ topic: changeDetails.topic,
223
288
  owner: removeUndefined(changeDetails.owner),
289
+ reviewers: changeDetails.reviewers.map((reviewer) =>
290
+ removeUndefined({
291
+ account_id: reviewer.accountId,
292
+ name: reviewer.name,
293
+ email: reviewer.email,
294
+ username: reviewer.username,
295
+ }),
296
+ ),
297
+ ccs: changeDetails.ccs.map((cc) =>
298
+ removeUndefined({
299
+ account_id: cc.accountId,
300
+ name: cc.name,
301
+ email: cc.email,
302
+ username: cc.username,
303
+ }),
304
+ ),
224
305
  created: changeDetails.created,
225
306
  updated: changeDetails.updated,
226
307
  }),
@@ -301,6 +382,9 @@ const formatShowXml = async (
301
382
  xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
302
383
  xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
303
384
  xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
385
+ if (changeDetails.topic) {
386
+ xmlParts.push(` <topic><![CDATA[${sanitizeCDATA(changeDetails.topic)}]]></topic>`)
387
+ }
304
388
  xmlParts.push(` <owner>`)
305
389
  if (changeDetails.owner.name) {
306
390
  xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
@@ -309,6 +393,44 @@ const formatShowXml = async (
309
393
  xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
310
394
  }
311
395
  xmlParts.push(` </owner>`)
396
+ xmlParts.push(` <reviewers>`)
397
+ xmlParts.push(` <count>${changeDetails.reviewers.length}</count>`)
398
+ for (const reviewer of changeDetails.reviewers) {
399
+ xmlParts.push(` <reviewer>`)
400
+ if (reviewer.accountId !== undefined) {
401
+ xmlParts.push(` <account_id>${reviewer.accountId}</account_id>`)
402
+ }
403
+ if (reviewer.name) {
404
+ xmlParts.push(` <name><![CDATA[${sanitizeCDATA(reviewer.name)}]]></name>`)
405
+ }
406
+ if (reviewer.email) {
407
+ xmlParts.push(` <email>${escapeXML(reviewer.email)}</email>`)
408
+ }
409
+ if (reviewer.username) {
410
+ xmlParts.push(` <username>${escapeXML(reviewer.username)}</username>`)
411
+ }
412
+ xmlParts.push(` </reviewer>`)
413
+ }
414
+ xmlParts.push(` </reviewers>`)
415
+ xmlParts.push(` <ccs>`)
416
+ xmlParts.push(` <count>${changeDetails.ccs.length}</count>`)
417
+ for (const cc of changeDetails.ccs) {
418
+ xmlParts.push(` <cc>`)
419
+ if (cc.accountId !== undefined) {
420
+ xmlParts.push(` <account_id>${cc.accountId}</account_id>`)
421
+ }
422
+ if (cc.name) {
423
+ xmlParts.push(` <name><![CDATA[${sanitizeCDATA(cc.name)}]]></name>`)
424
+ }
425
+ if (cc.email) {
426
+ xmlParts.push(` <email>${escapeXML(cc.email)}</email>`)
427
+ }
428
+ if (cc.username) {
429
+ xmlParts.push(` <username>${escapeXML(cc.username)}</username>`)
430
+ }
431
+ xmlParts.push(` </cc>`)
432
+ }
433
+ xmlParts.push(` </ccs>`)
312
434
  xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`)
313
435
  xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
314
436
  xmlParts.push(` </change>`)
@@ -0,0 +1,108 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
5
+
6
+ export const TOPIC_HELP_TEXT = `
7
+ Examples:
8
+ # View current topic (auto-detect from HEAD)
9
+ $ ger topic
10
+
11
+ # View topic for specific change
12
+ $ ger topic 12345
13
+
14
+ # Set topic on a change
15
+ $ ger topic 12345 my-feature
16
+
17
+ # Remove topic from a change
18
+ $ ger topic 12345 --delete
19
+ $ ger topic --delete # auto-detect from HEAD
20
+
21
+ Note: When no change-id is provided, it will be auto-detected from the HEAD commit.`
22
+
23
+ interface TopicOptions {
24
+ xml?: boolean
25
+ delete?: boolean
26
+ }
27
+
28
+ /**
29
+ * Manages topic for a Gerrit change.
30
+ *
31
+ * - No topic argument: get current topic
32
+ * - With topic argument: set topic
33
+ * - With --delete flag: remove topic
34
+ *
35
+ * @param changeId - Change number or Change-ID (auto-detects from HEAD if not provided)
36
+ * @param topic - Optional topic to set
37
+ * @param options - Configuration options
38
+ * @returns Effect that completes when the operation finishes
39
+ */
40
+ export const topicCommand = (
41
+ changeId: string | undefined,
42
+ topic: string | undefined,
43
+ options: TopicOptions = {},
44
+ ): Effect.Effect<void, ApiError | GitError | NoChangeIdError, GerritApiService> =>
45
+ Effect.gen(function* () {
46
+ const gerritApi = yield* GerritApiService
47
+
48
+ // Auto-detect Change-ID from HEAD commit if not provided
49
+ const resolvedChangeId = changeId?.trim() || (yield* getChangeIdFromHead())
50
+
51
+ // Handle delete operation
52
+ if (options.delete) {
53
+ yield* gerritApi.deleteTopic(resolvedChangeId)
54
+
55
+ if (options.xml) {
56
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
57
+ console.log(`<topic_result>`)
58
+ console.log(` <status>success</status>`)
59
+ console.log(` <action>deleted</action>`)
60
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
61
+ console.log(`</topic_result>`)
62
+ } else {
63
+ console.log(`✓ Removed topic from change ${resolvedChangeId}`)
64
+ }
65
+ return
66
+ }
67
+
68
+ // Handle set operation
69
+ if (topic !== undefined && topic.trim() !== '') {
70
+ const newTopic = yield* gerritApi.setTopic(resolvedChangeId, topic)
71
+
72
+ if (options.xml) {
73
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
74
+ console.log(`<topic_result>`)
75
+ console.log(` <status>success</status>`)
76
+ console.log(` <action>set</action>`)
77
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
78
+ console.log(` <topic><![CDATA[${sanitizeCDATA(newTopic)}]]></topic>`)
79
+ console.log(`</topic_result>`)
80
+ } else {
81
+ console.log(`✓ Set topic on change ${resolvedChangeId}: ${newTopic}`)
82
+ }
83
+ return
84
+ }
85
+
86
+ // Handle get operation (default)
87
+ const currentTopic = yield* gerritApi.getTopic(resolvedChangeId)
88
+
89
+ if (options.xml) {
90
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
91
+ console.log(`<topic_result>`)
92
+ console.log(` <status>success</status>`)
93
+ console.log(` <action>get</action>`)
94
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
95
+ if (currentTopic) {
96
+ console.log(` <topic><![CDATA[${sanitizeCDATA(currentTopic)}]]></topic>`)
97
+ } else {
98
+ console.log(` <topic />`)
99
+ }
100
+ console.log(`</topic_result>`)
101
+ } else {
102
+ if (currentTopic) {
103
+ console.log(currentTopic)
104
+ } else {
105
+ console.log(`No topic set for change ${resolvedChangeId}`)
106
+ }
107
+ }
108
+ })