@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/README.md +16 -0
- package/docs/adr/0023-show-reviewer-list.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/prd/commands.md +74 -1
- package/docs/prd/data-model.md +19 -8
- package/llms.txt +217 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +63 -60
- package/src/cli/commands/comment.ts +18 -0
- package/src/cli/commands/install-hook.ts +59 -0
- package/src/cli/commands/show.ts +123 -1
- package/src/cli/commands/topic.ts +108 -0
- package/src/cli/register-commands.ts +55 -33
- package/src/schemas/gerrit.ts +26 -8
- package/tests/show.test.ts +126 -0
- package/tests/submit.test.ts +28 -0
- package/tests/topic.test.ts +443 -0
- package/tests/unit/commands/install-hook.test.ts +258 -0
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
})
|
package/src/cli/commands/show.ts
CHANGED
|
@@ -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
|
+
})
|