@aaronshaf/ger 1.2.11 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -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 +83 -0
  6. package/.github/workflows/claude.yml +50 -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 +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -196
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,266 @@
1
+ import { Effect, Schema } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
4
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
5
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
6
+
7
+ // Schema for validating extract-url options
8
+ const ExtractUrlOptionsSchema: Schema.Schema<
9
+ {
10
+ readonly includeComments?: boolean
11
+ readonly regex?: boolean
12
+ readonly xml?: boolean
13
+ readonly json?: boolean
14
+ },
15
+ {
16
+ readonly includeComments?: boolean
17
+ readonly regex?: boolean
18
+ readonly xml?: boolean
19
+ readonly json?: boolean
20
+ }
21
+ > = Schema.Struct({
22
+ includeComments: Schema.optional(Schema.Boolean),
23
+ regex: Schema.optional(Schema.Boolean),
24
+ xml: Schema.optional(Schema.Boolean),
25
+ json: Schema.optional(Schema.Boolean),
26
+ })
27
+
28
+ export interface ExtractUrlOptions extends Schema.Schema.Type<typeof ExtractUrlOptionsSchema> {}
29
+
30
+ // Schema for validating pattern input
31
+ const PatternSchema: Schema.Schema<string, string> = Schema.String.pipe(
32
+ Schema.minLength(1, { message: () => 'Pattern cannot be empty' }),
33
+ Schema.maxLength(500, { message: () => 'Pattern is too long (max 500 characters)' }),
34
+ )
35
+
36
+ // URL matching regex - matches http:// and https:// URLs
37
+ const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
38
+
39
+ // Regex validation error class
40
+ export class RegexValidationError extends Error {
41
+ readonly _tag = 'RegexValidationError'
42
+ constructor(message: string) {
43
+ super(message)
44
+ this.name = 'RegexValidationError'
45
+ }
46
+ }
47
+
48
+ // Safely create regex with validation and timeout protection
49
+ const createSafeRegex = (pattern: string): Effect.Effect<RegExp, RegexValidationError> =>
50
+ Effect.try({
51
+ try: () => {
52
+ // Validate regex complexity by checking for dangerous patterns
53
+ // These patterns check for nested quantifiers that can cause ReDoS
54
+ const dangerousPatterns = [
55
+ /\([^)]*[+*][^)]*\)[+*]/, // Nested quantifiers like (a+)+ or (a*)*
56
+ /\([^)]*[+*][^)]*\)[+*?]/, // Nested quantifiers with ? like (a+)+?
57
+ /\[[^\]]*\][+*]{2,}/, // Character class with multiple quantifiers like [a-z]++
58
+ ]
59
+
60
+ for (const dangerous of dangerousPatterns) {
61
+ if (dangerous.test(pattern)) {
62
+ throw new RegexValidationError(
63
+ 'Pattern contains potentially dangerous nested quantifiers that could cause performance issues',
64
+ )
65
+ }
66
+ }
67
+
68
+ // Try to create the regex - this will throw if syntax is invalid
69
+ return new RegExp(pattern)
70
+ },
71
+ catch: (error) => {
72
+ if (error instanceof RegexValidationError) {
73
+ return error
74
+ }
75
+ return new RegexValidationError(
76
+ `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`,
77
+ )
78
+ },
79
+ })
80
+
81
+ const extractUrlsFromText = (
82
+ text: string,
83
+ pattern: string,
84
+ useRegex: boolean,
85
+ ): Effect.Effect<readonly string[], RegexValidationError> =>
86
+ Effect.gen(function* () {
87
+ // First, find all URLs in the text
88
+ const urls = text.match(URL_REGEX) || []
89
+
90
+ // Filter URLs by pattern
91
+ if (useRegex) {
92
+ const regex = yield* createSafeRegex(pattern)
93
+ return urls.filter((url) => regex.test(url))
94
+ } else {
95
+ // Substring match (case-insensitive)
96
+ const lowerPattern = pattern.toLowerCase()
97
+ return urls.filter((url) => url.toLowerCase().includes(lowerPattern))
98
+ }
99
+ })
100
+
101
+ const getCommentsAndMessages = (
102
+ changeId: string,
103
+ ): Effect.Effect<
104
+ { readonly comments: readonly CommentInfo[]; readonly messages: readonly MessageInfo[] },
105
+ ApiError,
106
+ GerritApiService
107
+ > =>
108
+ Effect.gen(function* () {
109
+ const gerritApi = yield* GerritApiService
110
+
111
+ // Get both inline comments and review messages concurrently
112
+ const [comments, messages] = yield* Effect.all(
113
+ [gerritApi.getComments(changeId), gerritApi.getMessages(changeId)],
114
+ { concurrency: 'unbounded' },
115
+ )
116
+
117
+ // Flatten all inline comments from all files using functional patterns
118
+ const allComments = Object.entries(comments).flatMap(([path, fileComments]) =>
119
+ fileComments.map((comment) => ({
120
+ ...comment,
121
+ path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
122
+ })),
123
+ )
124
+
125
+ // Sort inline comments by date (ascending - oldest first)
126
+ const sortedComments = [...allComments].sort((a, b) => {
127
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
128
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
129
+ return dateA - dateB
130
+ })
131
+
132
+ // Sort messages by date (ascending - oldest first)
133
+ const sortedMessages = [...messages].sort((a, b) => {
134
+ const dateA = new Date(a.date).getTime()
135
+ const dateB = new Date(b.date).getTime()
136
+ return dateA - dateB
137
+ })
138
+
139
+ return { comments: sortedComments, messages: sortedMessages }
140
+ })
141
+
142
+ const extractUrlsFromChange = (
143
+ changeId: string,
144
+ pattern: string,
145
+ options: ExtractUrlOptions,
146
+ ): Effect.Effect<readonly string[], ApiError | RegexValidationError, GerritApiService> =>
147
+ Effect.gen(function* () {
148
+ const { comments, messages } = yield* getCommentsAndMessages(changeId)
149
+
150
+ // Extract URLs from messages using functional patterns
151
+ const messageUrls = yield* Effect.all(
152
+ messages.map((message) =>
153
+ extractUrlsFromText(message.message, pattern, options.regex || false),
154
+ ),
155
+ { concurrency: 'unbounded' },
156
+ )
157
+
158
+ // Optionally extract URLs from comments
159
+ const commentUrls = options.includeComments
160
+ ? yield* Effect.all(
161
+ comments
162
+ .filter((comment) => comment.message !== undefined)
163
+ .map((comment) =>
164
+ extractUrlsFromText(comment.message!, pattern, options.regex || false),
165
+ ),
166
+ { concurrency: 'unbounded' },
167
+ )
168
+ : []
169
+
170
+ // Flatten all URLs
171
+ return [...messageUrls.flat(), ...commentUrls.flat()]
172
+ })
173
+
174
+ const formatUrlsPretty = (urls: readonly string[]): Effect.Effect<void> =>
175
+ Effect.sync(() => {
176
+ for (const url of urls) {
177
+ console.log(url)
178
+ }
179
+ })
180
+
181
+ const formatUrlsXml = (urls: readonly string[]): Effect.Effect<void> =>
182
+ Effect.sync(() => {
183
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
184
+ console.log(`<extract_url_result>`)
185
+ console.log(` <status>success</status>`)
186
+ console.log(` <urls>`)
187
+ console.log(` <count>${urls.length}</count>`)
188
+ for (const url of urls) {
189
+ console.log(` <url>${escapeXML(url)}</url>`)
190
+ }
191
+ console.log(` </urls>`)
192
+ console.log(`</extract_url_result>`)
193
+ })
194
+
195
+ const formatUrlsJson = (urls: readonly string[]): Effect.Effect<void> =>
196
+ Effect.sync(() => {
197
+ const output = {
198
+ status: 'success',
199
+ urls,
200
+ }
201
+ console.log(JSON.stringify(output, null, 2))
202
+ })
203
+
204
+ export const extractUrlCommand = (
205
+ pattern: string,
206
+ changeId: string | undefined,
207
+ options: ExtractUrlOptions,
208
+ ): Effect.Effect<
209
+ void,
210
+ ApiError | Error | GitError | NoChangeIdError | RegexValidationError,
211
+ GerritApiService
212
+ > =>
213
+ Effect.gen(function* () {
214
+ // Validate inputs using Effect Schema
215
+ const validatedPattern = yield* Schema.decodeUnknown(PatternSchema)(pattern)
216
+ const validatedOptions = yield* Schema.decodeUnknown(ExtractUrlOptionsSchema)(options)
217
+
218
+ // Auto-detect Change-ID from HEAD commit if not provided
219
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
220
+
221
+ // Extract URLs
222
+ const urls = yield* extractUrlsFromChange(resolvedChangeId, validatedPattern, validatedOptions)
223
+
224
+ // Format output using Effect-wrapped functions
225
+ if (validatedOptions.json) {
226
+ yield* formatUrlsJson(urls)
227
+ } else if (validatedOptions.xml) {
228
+ yield* formatUrlsXml(urls)
229
+ } else {
230
+ yield* formatUrlsPretty(urls)
231
+ }
232
+ }).pipe(
233
+ // Regional error boundary for the entire command
234
+ Effect.catchAll((error) =>
235
+ Effect.sync(() => {
236
+ const errorMessage =
237
+ error instanceof GitError ||
238
+ error instanceof NoChangeIdError ||
239
+ error instanceof RegexValidationError ||
240
+ error instanceof Error
241
+ ? error.message
242
+ : String(error)
243
+
244
+ if (options.json) {
245
+ console.log(
246
+ JSON.stringify(
247
+ {
248
+ status: 'error',
249
+ error: errorMessage,
250
+ },
251
+ null,
252
+ 2,
253
+ ),
254
+ )
255
+ } else if (options.xml) {
256
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
257
+ console.log(`<extract_url_result>`)
258
+ console.log(` <status>error</status>`)
259
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
260
+ console.log(`</extract_url_result>`)
261
+ } else {
262
+ console.error(`✗ Error: ${errorMessage}`)
263
+ }
264
+ }),
265
+ ),
266
+ )
@@ -0,0 +1,104 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface GroupsMembersOptions {
6
+ xml?: boolean
7
+ }
8
+
9
+ /**
10
+ * Lists all members of a Gerrit group.
11
+ *
12
+ * @param groupId - The group ID (numeric), UUID, or group name
13
+ * @param options - Configuration options
14
+ * @param options.xml - Whether to output in XML format for LLM consumption
15
+ * @returns Effect that completes when members are listed
16
+ */
17
+ export const groupsMembersCommand = (
18
+ groupId: string,
19
+ options: GroupsMembersOptions = {},
20
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
21
+ Effect.gen(function* () {
22
+ const gerritApi = yield* GerritApiService
23
+
24
+ // Fetch group members
25
+ const members = yield* gerritApi.getGroupMembers(groupId).pipe(
26
+ Effect.catchTag('ApiError', (error) =>
27
+ Effect.gen(function* () {
28
+ if (options.xml) {
29
+ console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
+ console.log('<group_members_result>')
31
+ console.log(' <status>error</status>')
32
+ console.log(` <error><![CDATA[${sanitizeCDATA(error.message)}]]></error>`)
33
+ console.log('</group_members_result>')
34
+ } else {
35
+ if (error.status === 404) {
36
+ console.error(`✗ Group "${groupId}" not found`)
37
+ } else if (error.status === 403) {
38
+ console.error(`✗ Permission denied to view members of group "${groupId}"`)
39
+ } else {
40
+ console.error(`✗ Failed to get group members: ${error.message}`)
41
+ }
42
+ }
43
+ return yield* Effect.fail(error)
44
+ }),
45
+ ),
46
+ )
47
+
48
+ // Handle empty results
49
+ if (members.length === 0) {
50
+ if (options.xml) {
51
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
52
+ console.log(`<group_members_result>`)
53
+ console.log(` <status>success</status>`)
54
+ console.log(` <group_id><![CDATA[${sanitizeCDATA(groupId)}]]></group_id>`)
55
+ console.log(` <count>0</count>`)
56
+ console.log(` <members />`)
57
+ console.log(`</group_members_result>`)
58
+ } else {
59
+ console.log(`Group "${groupId}" has no members`)
60
+ }
61
+ return
62
+ }
63
+
64
+ // Output results
65
+ if (options.xml) {
66
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
67
+ console.log(`<group_members_result>`)
68
+ console.log(` <status>success</status>`)
69
+ console.log(` <group_id><![CDATA[${sanitizeCDATA(groupId)}]]></group_id>`)
70
+ console.log(` <count>${members.length}</count>`)
71
+ console.log(` <members>`)
72
+ for (const member of members) {
73
+ console.log(` <member>`)
74
+ console.log(` <account_id>${member._account_id}</account_id>`)
75
+ if (member.name) {
76
+ console.log(` <name><![CDATA[${sanitizeCDATA(member.name)}]]></name>`)
77
+ }
78
+ if (member.email) {
79
+ console.log(` <email><![CDATA[${sanitizeCDATA(member.email)}]]></email>`)
80
+ }
81
+ if (member.username) {
82
+ console.log(` <username><![CDATA[${sanitizeCDATA(member.username)}]]></username>`)
83
+ }
84
+ console.log(` </member>`)
85
+ }
86
+ console.log(` </members>`)
87
+ console.log(`</group_members_result>`)
88
+ } else {
89
+ // Plain text output
90
+ console.log(`Members of "${groupId}" (${members.length}):\n`)
91
+ for (const member of members) {
92
+ const name = member.name || member.username || `Account ${member._account_id}`
93
+ console.log(name)
94
+ if (member.email) {
95
+ console.log(` Email: ${member.email}`)
96
+ }
97
+ if (member.username && member.username !== member.name) {
98
+ console.log(` Username: ${member.username}`)
99
+ }
100
+ console.log(` Account ID: ${member._account_id}`)
101
+ console.log('')
102
+ }
103
+ }
104
+ })
@@ -0,0 +1,169 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface GroupsShowOptions {
6
+ xml?: boolean
7
+ }
8
+
9
+ /**
10
+ * Shows detailed information about a specific Gerrit group.
11
+ *
12
+ * @param groupId - The group ID (numeric), UUID, or group name
13
+ * @param options - Configuration options
14
+ * @param options.xml - Whether to output in XML format for LLM consumption
15
+ * @returns Effect that completes when group details are displayed
16
+ */
17
+ export const groupsShowCommand = (
18
+ groupId: string,
19
+ options: GroupsShowOptions = {},
20
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
21
+ Effect.gen(function* () {
22
+ const gerritApi = yield* GerritApiService
23
+
24
+ // Fetch group details (includes members and subgroups)
25
+ const group = yield* gerritApi.getGroupDetail(groupId).pipe(
26
+ Effect.catchTag('ApiError', (error) =>
27
+ Effect.gen(function* () {
28
+ if (options.xml) {
29
+ console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
+ console.log('<group_detail_result>')
31
+ console.log(' <status>error</status>')
32
+ console.log(` <error><![CDATA[${sanitizeCDATA(error.message)}]]></error>`)
33
+ console.log('</group_detail_result>')
34
+ } else {
35
+ if (error.status === 404) {
36
+ console.error(`✗ Group "${groupId}" not found`)
37
+ } else if (error.status === 403) {
38
+ console.error(`✗ Permission denied to view group "${groupId}"`)
39
+ } else {
40
+ console.error(`✗ Failed to get group details: ${error.message}`)
41
+ }
42
+ }
43
+ return yield* Effect.fail(error)
44
+ }),
45
+ ),
46
+ )
47
+
48
+ // Output results
49
+ if (options.xml) {
50
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
51
+ console.log(`<group_detail_result>`)
52
+ console.log(` <status>success</status>`)
53
+ console.log(` <group>`)
54
+ console.log(` <id><![CDATA[${sanitizeCDATA(group.id)}]]></id>`)
55
+ if (group.name) {
56
+ console.log(` <name><![CDATA[${sanitizeCDATA(group.name)}]]></name>`)
57
+ }
58
+ if (group.description) {
59
+ console.log(
60
+ ` <description><![CDATA[${sanitizeCDATA(group.description)}]]></description>`,
61
+ )
62
+ }
63
+ if (group.owner) {
64
+ console.log(` <owner><![CDATA[${sanitizeCDATA(group.owner)}]]></owner>`)
65
+ }
66
+ if (group.owner_id) {
67
+ console.log(` <owner_id><![CDATA[${sanitizeCDATA(group.owner_id)}]]></owner_id>`)
68
+ }
69
+ if (group.group_id !== undefined) {
70
+ console.log(` <group_id>${group.group_id}</group_id>`)
71
+ }
72
+ if (group.options?.visible_to_all !== undefined) {
73
+ console.log(` <visible_to_all>${group.options.visible_to_all}</visible_to_all>`)
74
+ }
75
+ if (group.created_on) {
76
+ console.log(` <created_on><![CDATA[${sanitizeCDATA(group.created_on)}]]></created_on>`)
77
+ }
78
+ if (group.url) {
79
+ console.log(` <url><![CDATA[${sanitizeCDATA(group.url)}]]></url>`)
80
+ }
81
+
82
+ // Members
83
+ if (group.members && group.members.length > 0) {
84
+ console.log(` <members>`)
85
+ for (const member of group.members) {
86
+ console.log(` <member>`)
87
+ console.log(` <account_id>${member._account_id}</account_id>`)
88
+ if (member.name) {
89
+ console.log(` <name><![CDATA[${sanitizeCDATA(member.name)}]]></name>`)
90
+ }
91
+ if (member.email) {
92
+ console.log(` <email><![CDATA[${sanitizeCDATA(member.email)}]]></email>`)
93
+ }
94
+ if (member.username) {
95
+ console.log(
96
+ ` <username><![CDATA[${sanitizeCDATA(member.username)}]]></username>`,
97
+ )
98
+ }
99
+ console.log(` </member>`)
100
+ }
101
+ console.log(` </members>`)
102
+ }
103
+
104
+ // Subgroups
105
+ if (group.includes && group.includes.length > 0) {
106
+ console.log(` <subgroups>`)
107
+ for (const subgroup of group.includes) {
108
+ console.log(` <subgroup>`)
109
+ console.log(` <id><![CDATA[${sanitizeCDATA(subgroup.id)}]]></id>`)
110
+ if (subgroup.name) {
111
+ console.log(` <name><![CDATA[${sanitizeCDATA(subgroup.name)}]]></name>`)
112
+ }
113
+ console.log(` </subgroup>`)
114
+ }
115
+ console.log(` </subgroups>`)
116
+ }
117
+
118
+ console.log(` </group>`)
119
+ console.log(`</group_detail_result>`)
120
+ } else {
121
+ // Plain text output
122
+ const name = group.name || group.id
123
+ console.log(`Group: ${name}`)
124
+ console.log(`ID: ${group.id}`)
125
+ if (group.group_id !== undefined) {
126
+ console.log(`Numeric ID: ${group.group_id}`)
127
+ }
128
+ if (group.owner) {
129
+ console.log(`Owner: ${group.owner}`)
130
+ }
131
+ if (group.description) {
132
+ console.log(`Description: ${group.description}`)
133
+ }
134
+ if (group.options?.visible_to_all !== undefined) {
135
+ console.log(`Visible to all: ${group.options.visible_to_all ? 'Yes' : 'No'}`)
136
+ }
137
+ if (group.created_on) {
138
+ console.log(`Created: ${group.created_on}`)
139
+ }
140
+
141
+ // Members
142
+ if (group.members && group.members.length > 0) {
143
+ console.log(`\nMembers (${group.members.length}):`)
144
+ for (const member of group.members) {
145
+ const memberName = member.name || member.username || `Account ${member._account_id}`
146
+ console.log(` • ${memberName}`)
147
+ if (member.email) {
148
+ console.log(` Email: ${member.email}`)
149
+ }
150
+ if (member.username && member.username !== memberName) {
151
+ console.log(` Username: ${member.username}`)
152
+ }
153
+ }
154
+ } else {
155
+ console.log(`\nMembers: None`)
156
+ }
157
+
158
+ // Subgroups
159
+ if (group.includes && group.includes.length > 0) {
160
+ console.log(`\nSubgroups (${group.includes.length}):`)
161
+ for (const subgroup of group.includes) {
162
+ const subgroupName = subgroup.name || subgroup.id
163
+ console.log(` • ${subgroupName}`)
164
+ }
165
+ } else {
166
+ console.log(`\nSubgroups: None`)
167
+ }
168
+ }
169
+ })
@@ -0,0 +1,137 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface GroupsOptions {
6
+ pattern?: string
7
+ owned?: boolean
8
+ project?: string
9
+ user?: string
10
+ limit?: string
11
+ xml?: boolean
12
+ }
13
+
14
+ /**
15
+ * Lists Gerrit groups with optional filtering.
16
+ *
17
+ * @param options - Configuration options
18
+ * @param options.pattern - Optional regex pattern to filter groups by name
19
+ * @param options.owned - Show only groups owned by the current user
20
+ * @param options.project - Show groups with permissions on specific project
21
+ * @param options.user - Show groups a specific user belongs to
22
+ * @param options.limit - Maximum number of results (default: 25)
23
+ * @param options.xml - Whether to output in XML format for LLM consumption
24
+ * @returns Effect that completes when groups are listed
25
+ */
26
+ export const groupsCommand = (
27
+ options: GroupsOptions = {},
28
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
29
+ Effect.gen(function* () {
30
+ const gerritApi = yield* GerritApiService
31
+
32
+ // Parse limit option
33
+ const limit = options.limit ? Number.parseInt(options.limit, 10) : 25
34
+
35
+ // Fetch groups
36
+ const groups = yield* gerritApi
37
+ .listGroups({
38
+ pattern: options.pattern,
39
+ owned: options.owned,
40
+ project: options.project,
41
+ user: options.user,
42
+ limit: Number.isNaN(limit) || limit < 1 ? 25 : limit,
43
+ })
44
+ .pipe(
45
+ Effect.catchTag('ApiError', (error) =>
46
+ Effect.gen(function* () {
47
+ if (options.xml) {
48
+ console.log('<?xml version="1.0" encoding="UTF-8"?>')
49
+ console.log('<groups_result>')
50
+ console.log(' <status>error</status>')
51
+ console.log(` <error><![CDATA[${sanitizeCDATA(error.message)}]]></error>`)
52
+ console.log('</groups_result>')
53
+ } else {
54
+ if (error.status === 403) {
55
+ console.error('✗ Permission denied to list groups')
56
+ } else {
57
+ console.error(`✗ Failed to list groups: ${error.message}`)
58
+ }
59
+ }
60
+ return yield* Effect.fail(error)
61
+ }),
62
+ ),
63
+ )
64
+
65
+ // Handle empty results
66
+ if (groups.length === 0) {
67
+ if (options.xml) {
68
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
69
+ console.log(`<groups_result>`)
70
+ console.log(` <status>success</status>`)
71
+ console.log(` <count>0</count>`)
72
+ console.log(` <groups />`)
73
+ console.log(`</groups_result>`)
74
+ } else {
75
+ console.log('No groups found')
76
+ }
77
+ return
78
+ }
79
+
80
+ // Output results
81
+ if (options.xml) {
82
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
83
+ console.log(`<groups_result>`)
84
+ console.log(` <status>success</status>`)
85
+ console.log(` <count>${groups.length}</count>`)
86
+ console.log(` <groups>`)
87
+ for (const group of groups) {
88
+ console.log(` <group>`)
89
+ console.log(` <id><![CDATA[${sanitizeCDATA(group.id)}]]></id>`)
90
+ if (group.name) {
91
+ console.log(` <name><![CDATA[${sanitizeCDATA(group.name)}]]></name>`)
92
+ }
93
+ if (group.description) {
94
+ console.log(
95
+ ` <description><![CDATA[${sanitizeCDATA(group.description)}]]></description>`,
96
+ )
97
+ }
98
+ if (group.owner) {
99
+ console.log(` <owner><![CDATA[${sanitizeCDATA(group.owner)}]]></owner>`)
100
+ }
101
+ if (group.owner_id) {
102
+ console.log(` <owner_id><![CDATA[${sanitizeCDATA(group.owner_id)}]]></owner_id>`)
103
+ }
104
+ if (group.group_id !== undefined) {
105
+ console.log(` <group_id>${group.group_id}</group_id>`)
106
+ }
107
+ if (group.options?.visible_to_all !== undefined) {
108
+ console.log(` <visible_to_all>${group.options.visible_to_all}</visible_to_all>`)
109
+ }
110
+ if (group.created_on) {
111
+ console.log(
112
+ ` <created_on><![CDATA[${sanitizeCDATA(group.created_on)}]]></created_on>`,
113
+ )
114
+ }
115
+ if (group.url) {
116
+ console.log(` <url><![CDATA[${sanitizeCDATA(group.url)}]]></url>`)
117
+ }
118
+ console.log(` </group>`)
119
+ }
120
+ console.log(` </groups>`)
121
+ console.log(`</groups_result>`)
122
+ } else {
123
+ // Plain text output - more detailed than projects
124
+ console.log(`Available groups (${groups.length}):\n`)
125
+ for (const group of groups) {
126
+ const name = group.name || group.id
127
+ console.log(name)
128
+ if (group.description) {
129
+ console.log(` Description: ${group.description}`)
130
+ }
131
+ if (group.owner) {
132
+ console.log(` Owner: ${group.owner}`)
133
+ }
134
+ console.log('')
135
+ }
136
+ }
137
+ })