@aaronshaf/ger 1.2.10 → 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 -180
  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,696 @@
1
+ import { Schema } from '@effect/schema'
2
+ import { Context, Effect, Layer } from 'effect'
3
+ import {
4
+ ChangeInfo,
5
+ CommentInfo,
6
+ MessageInfo,
7
+ type DiffOptions,
8
+ FileDiffContent,
9
+ FileInfo,
10
+ type GerritCredentials,
11
+ ProjectInfo,
12
+ type ReviewInput,
13
+ type ReviewerInput,
14
+ ReviewerResult,
15
+ RevisionInfo,
16
+ SubmitInfo,
17
+ GroupInfo,
18
+ GroupDetailInfo,
19
+ AccountInfo,
20
+ } from '@/schemas/gerrit'
21
+ import { filterMeaningfulMessages } from '@/utils/message-filters'
22
+ import { ConfigService } from '@/services/config'
23
+ import { normalizeChangeIdentifier } from '@/utils/change-id'
24
+
25
+ export interface GerritApiServiceImpl {
26
+ readonly getChange: (changeId: string) => Effect.Effect<ChangeInfo, ApiError>
27
+ readonly listChanges: (query?: string) => Effect.Effect<readonly ChangeInfo[], ApiError>
28
+ readonly listProjects: (options?: {
29
+ pattern?: string
30
+ }) => Effect.Effect<readonly ProjectInfo[], ApiError>
31
+ readonly postReview: (changeId: string, review: ReviewInput) => Effect.Effect<void, ApiError>
32
+ readonly abandonChange: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
33
+ readonly restoreChange: (
34
+ changeId: string,
35
+ message?: string,
36
+ ) => Effect.Effect<ChangeInfo, ApiError>
37
+ readonly rebaseChange: (
38
+ changeId: string,
39
+ options?: { base?: string },
40
+ ) => Effect.Effect<ChangeInfo, ApiError>
41
+ readonly submitChange: (changeId: string) => Effect.Effect<SubmitInfo, ApiError>
42
+ readonly testConnection: Effect.Effect<boolean, ApiError>
43
+ readonly getRevision: (
44
+ changeId: string,
45
+ revisionId?: string,
46
+ ) => Effect.Effect<RevisionInfo, ApiError>
47
+ readonly getFiles: (
48
+ changeId: string,
49
+ revisionId?: string,
50
+ ) => Effect.Effect<Record<string, FileInfo>, ApiError>
51
+ readonly getFileDiff: (
52
+ changeId: string,
53
+ filePath: string,
54
+ revisionId?: string,
55
+ base?: string,
56
+ ) => Effect.Effect<FileDiffContent, ApiError>
57
+ readonly getFileContent: (
58
+ changeId: string,
59
+ filePath: string,
60
+ revisionId?: string,
61
+ ) => Effect.Effect<string, ApiError>
62
+ readonly getPatch: (changeId: string, revisionId?: string) => Effect.Effect<string, ApiError>
63
+ readonly getDiff: (
64
+ changeId: string,
65
+ options?: DiffOptions,
66
+ ) => Effect.Effect<string | string[] | Record<string, unknown> | FileDiffContent, ApiError>
67
+ readonly getComments: (
68
+ changeId: string,
69
+ revisionId?: string,
70
+ ) => Effect.Effect<Record<string, readonly CommentInfo[]>, ApiError>
71
+ readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
72
+ readonly addReviewer: (
73
+ changeId: string,
74
+ reviewer: string,
75
+ options?: { state?: 'REVIEWER' | 'CC'; notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
76
+ ) => Effect.Effect<ReviewerResult, ApiError>
77
+ readonly listGroups: (options?: {
78
+ owned?: boolean
79
+ project?: string
80
+ user?: string
81
+ pattern?: string
82
+ limit?: number
83
+ skip?: number
84
+ }) => Effect.Effect<readonly GroupInfo[], ApiError>
85
+ readonly getGroup: (groupId: string) => Effect.Effect<GroupInfo, ApiError>
86
+ readonly getGroupDetail: (groupId: string) => Effect.Effect<GroupDetailInfo, ApiError>
87
+ readonly getGroupMembers: (groupId: string) => Effect.Effect<readonly AccountInfo[], ApiError>
88
+ readonly removeReviewer: (
89
+ changeId: string,
90
+ accountId: string,
91
+ options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
92
+ ) => Effect.Effect<void, ApiError>
93
+ }
94
+
95
+ // Export both the tag value and the type for use in Effect requirements
96
+ export const GerritApiService: Context.Tag<GerritApiServiceImpl, GerritApiServiceImpl> =
97
+ Context.GenericTag<GerritApiServiceImpl>('GerritApiService')
98
+ export type GerritApiService = Context.Tag.Identifier<typeof GerritApiService>
99
+
100
+ // Export ApiError fields interface explicitly
101
+ export interface ApiErrorFields {
102
+ readonly message: string
103
+ readonly status?: number
104
+ }
105
+
106
+ // Define error schema (not exported, so type can be implicit)
107
+ const ApiErrorSchema = Schema.TaggedError<ApiErrorFields>()('ApiError', {
108
+ message: Schema.String,
109
+ status: Schema.optional(Schema.Number),
110
+ } as const) as unknown
111
+
112
+ // Export the error class with explicit constructor signature for isolatedDeclarations
113
+ export class ApiError
114
+ extends (ApiErrorSchema as new (
115
+ args: ApiErrorFields,
116
+ ) => ApiErrorFields & Error & { readonly _tag: 'ApiError' })
117
+ implements Error
118
+ {
119
+ readonly name = 'ApiError'
120
+ }
121
+
122
+ const createAuthHeader = (credentials: GerritCredentials): string => {
123
+ const auth = btoa(`${credentials.username}:${credentials.password}`)
124
+ return `Basic ${auth}`
125
+ }
126
+
127
+ const makeRequest = <T = unknown>(
128
+ url: string,
129
+ authHeader: string,
130
+ method: 'GET' | 'POST' = 'GET',
131
+ body?: unknown,
132
+ schema?: Schema.Schema<T>,
133
+ ): Effect.Effect<T, ApiError> =>
134
+ Effect.gen(function* () {
135
+ const headers: Record<string, string> = {
136
+ Authorization: authHeader,
137
+ }
138
+
139
+ if (body) {
140
+ headers['Content-Type'] = 'application/json'
141
+ }
142
+
143
+ const response = yield* Effect.tryPromise({
144
+ try: () =>
145
+ fetch(url, {
146
+ method,
147
+ headers,
148
+ ...(method !== 'GET' && body ? { body: JSON.stringify(body) } : {}),
149
+ }),
150
+ catch: () => new ApiError({ message: 'Request failed - network or authentication error' }),
151
+ })
152
+
153
+ if (!response.ok) {
154
+ const errorText = yield* Effect.tryPromise({
155
+ try: () => response.text(),
156
+ catch: () => 'Unknown error',
157
+ }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
158
+ yield* Effect.fail(
159
+ new ApiError({
160
+ message: errorText,
161
+ status: response.status,
162
+ }),
163
+ )
164
+ }
165
+
166
+ const text = yield* Effect.tryPromise({
167
+ try: () => response.text(),
168
+ catch: () => new ApiError({ message: 'Failed to read response data' }),
169
+ })
170
+
171
+ // Gerrit returns JSON with )]}' prefix for security
172
+ 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
+ }
178
+
179
+ const parsed = yield* Effect.try({
180
+ try: () => JSON.parse(cleanJson),
181
+ catch: () => new ApiError({ message: 'Failed to parse response - invalid JSON format' }),
182
+ })
183
+
184
+ if (schema) {
185
+ return yield* Schema.decodeUnknown(schema)(parsed).pipe(
186
+ Effect.mapError(() => new ApiError({ message: 'Invalid response format from server' })),
187
+ )
188
+ }
189
+
190
+ // When no schema is provided, the caller expects void or doesn't care about the response
191
+ return parsed
192
+ })
193
+
194
+ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigService> =
195
+ Layer.effect(
196
+ GerritApiService,
197
+ Effect.gen(function* () {
198
+ const configService = yield* ConfigService
199
+
200
+ const getCredentialsAndAuth = Effect.gen(function* () {
201
+ const credentials = yield* configService.getCredentials.pipe(
202
+ Effect.mapError(() => new ApiError({ message: 'Failed to get credentials' })),
203
+ )
204
+ // Ensure host doesn't have trailing slash
205
+ const normalizedCredentials = {
206
+ ...credentials,
207
+ host: credentials.host.replace(/\/$/, ''),
208
+ }
209
+ const authHeader = createAuthHeader(normalizedCredentials)
210
+ return { credentials: normalizedCredentials, authHeader }
211
+ })
212
+
213
+ // Helper to normalize and validate change identifier
214
+ const normalizeAndValidate = (changeId: string): Effect.Effect<string, ApiError> =>
215
+ Effect.try({
216
+ try: () => normalizeChangeIdentifier(changeId),
217
+ catch: (error) =>
218
+ new ApiError({
219
+ message: error instanceof Error ? error.message : String(error),
220
+ }),
221
+ })
222
+
223
+ const getChange = (changeId: string) =>
224
+ Effect.gen(function* () {
225
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
226
+ const normalized = yield* normalizeAndValidate(changeId)
227
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=CURRENT_REVISION&o=CURRENT_COMMIT`
228
+ return yield* makeRequest(url, authHeader, 'GET', undefined, ChangeInfo)
229
+ })
230
+
231
+ const listChanges = (query = 'is:open') =>
232
+ Effect.gen(function* () {
233
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
234
+ const encodedQuery = encodeURIComponent(query)
235
+ // Add additional options to get detailed information
236
+ const url = `${credentials.host}/a/changes/?q=${encodedQuery}&o=LABELS&o=DETAILED_LABELS&o=DETAILED_ACCOUNTS&o=SUBMITTABLE`
237
+ return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(ChangeInfo))
238
+ })
239
+
240
+ const listProjects = (options?: { pattern?: string }) =>
241
+ Effect.gen(function* () {
242
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
243
+ 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
248
+ const projectsRecord = yield* makeRequest(
249
+ url,
250
+ authHeader,
251
+ 'GET',
252
+ undefined,
253
+ Schema.Record({ key: Schema.String, value: ProjectInfo }),
254
+ )
255
+ // Convert Record to Array and sort alphabetically by name
256
+ return Object.values(projectsRecord).sort((a, b) => a.name.localeCompare(b.name))
257
+ })
258
+
259
+ const postReview = (changeId: string, review: ReviewInput) =>
260
+ Effect.gen(function* () {
261
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
262
+ const normalized = yield* normalizeAndValidate(changeId)
263
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/review`
264
+ yield* makeRequest(url, authHeader, 'POST', review)
265
+ })
266
+
267
+ const abandonChange = (changeId: string, message?: string) =>
268
+ Effect.gen(function* () {
269
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
270
+ const normalized = yield* normalizeAndValidate(changeId)
271
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/abandon`
272
+ const body = message ? { message } : {}
273
+ yield* makeRequest(url, authHeader, 'POST', body)
274
+ })
275
+
276
+ const restoreChange = (changeId: string, message?: string) =>
277
+ Effect.gen(function* () {
278
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
279
+ const normalized = yield* normalizeAndValidate(changeId)
280
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/restore`
281
+ const body = message ? { message } : {}
282
+ return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo)
283
+ })
284
+
285
+ const rebaseChange = (changeId: string, options?: { base?: string }) =>
286
+ Effect.gen(function* () {
287
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
288
+ const normalized = yield* normalizeAndValidate(changeId)
289
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/rebase`
290
+ const body = options?.base ? { base: options.base } : {}
291
+ return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo)
292
+ })
293
+
294
+ const submitChange = (changeId: string) =>
295
+ Effect.gen(function* () {
296
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
297
+ const normalized = yield* normalizeAndValidate(changeId)
298
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/submit`
299
+ return yield* makeRequest(url, authHeader, 'POST', {}, SubmitInfo)
300
+ })
301
+
302
+ const testConnection = Effect.gen(function* () {
303
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
304
+ const url = `${credentials.host}/a/accounts/self`
305
+ yield* makeRequest(url, authHeader)
306
+ return true
307
+ }).pipe(
308
+ Effect.catchAll((error) => {
309
+ // Log the actual error for debugging
310
+ if (process.env.DEBUG) {
311
+ console.error('Connection error:', error)
312
+ }
313
+ return Effect.succeed(false)
314
+ }),
315
+ )
316
+
317
+ const getRevision = (changeId: string, revisionId = 'current') =>
318
+ Effect.gen(function* () {
319
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
320
+ const normalized = yield* normalizeAndValidate(changeId)
321
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}`
322
+ return yield* makeRequest(url, authHeader, 'GET', undefined, RevisionInfo)
323
+ })
324
+
325
+ const getFiles = (changeId: string, revisionId = 'current') =>
326
+ Effect.gen(function* () {
327
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
328
+ const normalized = yield* normalizeAndValidate(changeId)
329
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files`
330
+ return yield* makeRequest(
331
+ url,
332
+ authHeader,
333
+ 'GET',
334
+ undefined,
335
+ Schema.Record({ key: Schema.String, value: FileInfo }),
336
+ )
337
+ })
338
+
339
+ const getFileDiff = (
340
+ changeId: string,
341
+ filePath: string,
342
+ revisionId = 'current',
343
+ base?: string,
344
+ ) =>
345
+ Effect.gen(function* () {
346
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
347
+ const normalized = yield* normalizeAndValidate(changeId)
348
+ let url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/diff`
349
+ if (base) {
350
+ url += `?base=${encodeURIComponent(base)}`
351
+ }
352
+ return yield* makeRequest(url, authHeader, 'GET', undefined, FileDiffContent)
353
+ })
354
+
355
+ const getFileContent = (changeId: string, filePath: string, revisionId = 'current') =>
356
+ Effect.gen(function* () {
357
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
358
+ const normalized = yield* normalizeAndValidate(changeId)
359
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content`
360
+
361
+ const response = yield* Effect.tryPromise({
362
+ try: () =>
363
+ fetch(url, {
364
+ method: 'GET',
365
+ headers: { Authorization: authHeader },
366
+ }),
367
+ catch: () =>
368
+ new ApiError({ message: 'Request failed - network or authentication error' }),
369
+ })
370
+
371
+ if (!response.ok) {
372
+ const errorText = yield* Effect.tryPromise({
373
+ try: () => response.text(),
374
+ catch: () => 'Unknown error',
375
+ }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
376
+
377
+ yield* Effect.fail(
378
+ new ApiError({
379
+ message: errorText,
380
+ status: response.status,
381
+ }),
382
+ )
383
+ }
384
+
385
+ const base64Content = yield* Effect.tryPromise({
386
+ try: () => response.text(),
387
+ catch: () => new ApiError({ message: 'Failed to read response data' }),
388
+ })
389
+
390
+ return yield* Effect.try({
391
+ try: () => atob(base64Content),
392
+ catch: () => new ApiError({ message: 'Failed to decode file content' }),
393
+ })
394
+ })
395
+
396
+ const getPatch = (changeId: string, revisionId = 'current') =>
397
+ Effect.gen(function* () {
398
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
399
+ const normalized = yield* normalizeAndValidate(changeId)
400
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/patch`
401
+
402
+ const response = yield* Effect.tryPromise({
403
+ try: () =>
404
+ fetch(url, {
405
+ method: 'GET',
406
+ headers: { Authorization: authHeader },
407
+ }),
408
+ catch: () =>
409
+ new ApiError({ message: 'Request failed - network or authentication error' }),
410
+ })
411
+
412
+ if (!response.ok) {
413
+ const errorText = yield* Effect.tryPromise({
414
+ try: () => response.text(),
415
+ catch: () => 'Unknown error',
416
+ }).pipe(Effect.orElseSucceed(() => 'Unknown error'))
417
+
418
+ yield* Effect.fail(
419
+ new ApiError({
420
+ message: errorText,
421
+ status: response.status,
422
+ }),
423
+ )
424
+ }
425
+
426
+ const base64Patch = yield* Effect.tryPromise({
427
+ try: () => response.text(),
428
+ catch: () => new ApiError({ message: 'Failed to read response data' }),
429
+ })
430
+
431
+ return yield* Effect.try({
432
+ try: () => atob(base64Patch),
433
+ catch: () => new ApiError({ message: 'Failed to decode patch data' }),
434
+ })
435
+ })
436
+
437
+ const getDiff = (changeId: string, options: DiffOptions = {}) =>
438
+ Effect.gen(function* () {
439
+ const format = options.format || 'unified'
440
+ const revisionId = options.patchset ? `${options.patchset}` : 'current'
441
+
442
+ if (format === 'files') {
443
+ const files = yield* getFiles(changeId, revisionId)
444
+ return Object.keys(files)
445
+ }
446
+
447
+ if (options.file) {
448
+ if (format === 'json') {
449
+ const diff = yield* getFileDiff(
450
+ changeId,
451
+ options.file,
452
+ revisionId,
453
+ options.base ? `${options.base}` : undefined,
454
+ )
455
+ return diff
456
+ } else {
457
+ const diff = yield* getFileDiff(
458
+ changeId,
459
+ options.file,
460
+ revisionId,
461
+ options.base ? `${options.base}` : undefined,
462
+ )
463
+ return convertToUnifiedDiff(diff, options.file)
464
+ }
465
+ }
466
+
467
+ if (options.fullFiles) {
468
+ const files = yield* getFiles(changeId, revisionId)
469
+ const result: Record<string, string> = {}
470
+
471
+ for (const [filePath, _fileInfo] of Object.entries(files)) {
472
+ if (filePath === '/COMMIT_MSG' || filePath === '/MERGE_LIST') continue
473
+
474
+ const content = yield* getFileContent(changeId, filePath, revisionId).pipe(
475
+ Effect.catchAll(() => Effect.succeed('Binary file or permission denied')),
476
+ )
477
+ result[filePath] = content
478
+ }
479
+
480
+ return format === 'json'
481
+ ? result
482
+ : Object.entries(result)
483
+ .map(([path, content]) => `=== ${path} ===\n${content}\n`)
484
+ .join('\n')
485
+ }
486
+
487
+ if (format === 'json') {
488
+ const files = yield* getFiles(changeId, revisionId)
489
+ return files
490
+ }
491
+
492
+ return yield* getPatch(changeId, revisionId)
493
+ })
494
+
495
+ const convertToUnifiedDiff = (diff: FileDiffContent, filePath: string): string => {
496
+ const lines: string[] = []
497
+
498
+ if (diff.diff_header) {
499
+ lines.push(...diff.diff_header)
500
+ } else {
501
+ lines.push(`--- a/${filePath}`)
502
+ lines.push(`+++ b/${filePath}`)
503
+ }
504
+
505
+ for (const section of diff.content) {
506
+ if (section.ab) {
507
+ for (const line of section.ab) {
508
+ lines.push(` ${line}`)
509
+ }
510
+ }
511
+
512
+ if (section.a) {
513
+ for (const line of section.a) {
514
+ lines.push(`-${line}`)
515
+ }
516
+ }
517
+
518
+ if (section.b) {
519
+ for (const line of section.b) {
520
+ lines.push(`+${line}`)
521
+ }
522
+ }
523
+ }
524
+
525
+ return lines.join('\n')
526
+ }
527
+
528
+ const getComments = (changeId: string, revisionId = 'current') =>
529
+ Effect.gen(function* () {
530
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
531
+ const normalized = yield* normalizeAndValidate(changeId)
532
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/comments`
533
+ return yield* makeRequest(
534
+ url,
535
+ authHeader,
536
+ 'GET',
537
+ undefined,
538
+ Schema.Record({ key: Schema.String, value: Schema.Array(CommentInfo) }),
539
+ )
540
+ })
541
+
542
+ const getMessages = (changeId: string) =>
543
+ Effect.gen(function* () {
544
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
545
+ const normalized = yield* normalizeAndValidate(changeId)
546
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=MESSAGES`
547
+ const response = yield* makeRequest(url, authHeader, 'GET')
548
+
549
+ // Extract messages from the change response with runtime validation
550
+ const changeResponse = yield* Schema.decodeUnknown(
551
+ Schema.Struct({
552
+ messages: Schema.optional(Schema.Array(MessageInfo)),
553
+ }),
554
+ )(response).pipe(
555
+ Effect.mapError(
556
+ () => new ApiError({ message: 'Invalid messages response format from server' }),
557
+ ),
558
+ )
559
+
560
+ return changeResponse.messages || []
561
+ }).pipe(Effect.map(filterMeaningfulMessages))
562
+
563
+ const addReviewer = (
564
+ changeId: string,
565
+ reviewer: string,
566
+ options?: {
567
+ state?: 'REVIEWER' | 'CC'
568
+ notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
569
+ },
570
+ ) =>
571
+ Effect.gen(function* () {
572
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
573
+ const normalized = yield* normalizeAndValidate(changeId)
574
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers`
575
+ const body: ReviewerInput = {
576
+ reviewer,
577
+ ...(options?.state && { state: options.state }),
578
+ ...(options?.notify && { notify: options.notify }),
579
+ }
580
+ return yield* makeRequest(url, authHeader, 'POST', body, ReviewerResult)
581
+ })
582
+
583
+ const listGroups = (options?: {
584
+ owned?: boolean
585
+ project?: string
586
+ user?: string
587
+ pattern?: string
588
+ limit?: number
589
+ skip?: number
590
+ }) =>
591
+ Effect.gen(function* () {
592
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
593
+ let url = `${credentials.host}/a/groups/`
594
+ const params: string[] = []
595
+
596
+ if (options?.owned) {
597
+ params.push('owned')
598
+ }
599
+ if (options?.project) {
600
+ params.push(`p=${encodeURIComponent(options.project)}`)
601
+ }
602
+ if (options?.user) {
603
+ params.push(`user=${encodeURIComponent(options.user)}`)
604
+ }
605
+ if (options?.pattern) {
606
+ params.push(`r=${encodeURIComponent(options.pattern)}`)
607
+ }
608
+ if (options?.limit) {
609
+ params.push(`n=${options.limit}`)
610
+ }
611
+ if (options?.skip) {
612
+ params.push(`S=${options.skip}`)
613
+ }
614
+
615
+ if (params.length > 0) {
616
+ url += `?${params.join('&')}`
617
+ }
618
+
619
+ // Gerrit returns groups as a Record, need to convert to array
620
+ const groupsRecord = yield* makeRequest(
621
+ url,
622
+ authHeader,
623
+ 'GET',
624
+ undefined,
625
+ Schema.Record({ key: Schema.String, value: GroupInfo }),
626
+ )
627
+ // Convert Record to Array and sort alphabetically by name
628
+ return Object.values(groupsRecord).sort((a, b) => {
629
+ const aName = a.name || a.id
630
+ const bName = b.name || b.id
631
+ return aName.localeCompare(bName)
632
+ })
633
+ })
634
+
635
+ const getGroup = (groupId: string) =>
636
+ Effect.gen(function* () {
637
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
638
+ const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}`
639
+ return yield* makeRequest(url, authHeader, 'GET', undefined, GroupInfo)
640
+ })
641
+
642
+ const getGroupDetail = (groupId: string) =>
643
+ Effect.gen(function* () {
644
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
645
+ const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}/detail`
646
+ return yield* makeRequest(url, authHeader, 'GET', undefined, GroupDetailInfo)
647
+ })
648
+
649
+ const getGroupMembers = (groupId: string) =>
650
+ Effect.gen(function* () {
651
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
652
+ const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}/members/`
653
+ return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(AccountInfo))
654
+ })
655
+
656
+ const removeReviewer = (
657
+ changeId: string,
658
+ accountId: string,
659
+ options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
660
+ ) =>
661
+ Effect.gen(function* () {
662
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
663
+ const normalized = yield* normalizeAndValidate(changeId)
664
+ // Use POST to /delete endpoint to support request body with notify option
665
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers/${encodeURIComponent(accountId)}/delete`
666
+ const body = options?.notify ? { notify: options.notify } : {}
667
+ yield* makeRequest(url, authHeader, 'POST', body)
668
+ })
669
+
670
+ return {
671
+ getChange,
672
+ listChanges,
673
+ listProjects,
674
+ postReview,
675
+ abandonChange,
676
+ restoreChange,
677
+ rebaseChange,
678
+ submitChange,
679
+ testConnection,
680
+ getRevision,
681
+ getFiles,
682
+ getFileDiff,
683
+ getFileContent,
684
+ getPatch,
685
+ getDiff,
686
+ getComments,
687
+ getMessages,
688
+ addReviewer,
689
+ listGroups,
690
+ getGroup,
691
+ getGroupDetail,
692
+ getGroupMembers,
693
+ removeReviewer,
694
+ }
695
+ }),
696
+ )