@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +708 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -196
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- 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
|
+
})
|