@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,314 @@
1
+ import { Effect, Context, Layer, Console, pipe } from 'effect'
2
+ import { Schema } from 'effect'
3
+ import * as fs from 'node:fs'
4
+ import * as path from 'node:path'
5
+ import { execSync, spawnSync } from 'node:child_process'
6
+ import { ConfigService, type ConfigServiceImpl } from '@/services/config'
7
+
8
+ // Error types
9
+ //
10
+ // NOTE: The `as unknown` casts below are a workaround for Effect Schema's TaggedError
11
+ // type inference limitations. Schema.TaggedError returns a complex union type that
12
+ // doesn't directly satisfy the class extension pattern we need. The cast allows us
13
+ // to extend the schema as a class while maintaining the tagged error behavior.
14
+ // This pattern is used consistently across the codebase for Effect Schema errors.
15
+ // See: https://effect.website/docs/schema/basic-usage#tagged-errors
16
+
17
+ export interface HookInstallErrorFields {
18
+ readonly message: string
19
+ readonly cause?: unknown
20
+ }
21
+
22
+ const HookInstallErrorSchema = Schema.TaggedError<HookInstallErrorFields>()('HookInstallError', {
23
+ message: Schema.String,
24
+ cause: Schema.optional(Schema.Unknown),
25
+ }) as unknown
26
+
27
+ export class HookInstallError
28
+ extends (HookInstallErrorSchema as new (
29
+ args: HookInstallErrorFields,
30
+ ) => HookInstallErrorFields & Error & { readonly _tag: 'HookInstallError' })
31
+ implements Error
32
+ {
33
+ readonly name = 'HookInstallError'
34
+ }
35
+
36
+ export interface MissingChangeIdErrorFields {
37
+ readonly message: string
38
+ }
39
+
40
+ const MissingChangeIdErrorSchema = Schema.TaggedError<MissingChangeIdErrorFields>()(
41
+ 'MissingChangeIdError',
42
+ {
43
+ message: Schema.String,
44
+ },
45
+ ) as unknown
46
+
47
+ export class MissingChangeIdError
48
+ extends (MissingChangeIdErrorSchema as new (
49
+ args: MissingChangeIdErrorFields,
50
+ ) => MissingChangeIdErrorFields & Error & { readonly _tag: 'MissingChangeIdError' })
51
+ implements Error
52
+ {
53
+ readonly name = 'MissingChangeIdError'
54
+ }
55
+
56
+ export interface NotGitRepoErrorFields {
57
+ readonly message: string
58
+ }
59
+
60
+ const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
61
+ message: Schema.String,
62
+ }) as unknown
63
+
64
+ export class NotGitRepoError
65
+ extends (NotGitRepoErrorSchema as new (
66
+ args: NotGitRepoErrorFields,
67
+ ) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' })
68
+ implements Error
69
+ {
70
+ readonly name = 'NotGitRepoError'
71
+ }
72
+
73
+ export type CommitHookError = HookInstallError | MissingChangeIdError | NotGitRepoError
74
+
75
+ /** Regex pattern to match Gerrit Change-Id in commit messages */
76
+ export const CHANGE_ID_PATTERN: RegExp = /^Change-Id: I[0-9a-f]{40}$/m
77
+
78
+ // Get .git directory path (handles both regular repos and worktrees)
79
+ export const getGitDir = (): string => {
80
+ try {
81
+ return execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim()
82
+ } catch {
83
+ throw new Error('Not in a git repository')
84
+ }
85
+ }
86
+
87
+ // Get absolute .git directory path
88
+ export const getGitDirAbsolute = (): string => {
89
+ try {
90
+ return execSync('git rev-parse --absolute-git-dir', { encoding: 'utf8' }).trim()
91
+ } catch {
92
+ throw new Error('Not in a git repository')
93
+ }
94
+ }
95
+
96
+ // Check if commit-msg hook exists and is executable
97
+ export const hasCommitMsgHook = (): boolean => {
98
+ try {
99
+ const gitDir = getGitDir()
100
+ const hookPath = path.join(gitDir, 'hooks', 'commit-msg')
101
+
102
+ if (!fs.existsSync(hookPath)) {
103
+ return false
104
+ }
105
+
106
+ // Check if file is executable
107
+ const stats = fs.statSync(hookPath)
108
+ // Check owner execute bit (0o100)
109
+ return (stats.mode & 0o100) !== 0
110
+ } catch {
111
+ return false
112
+ }
113
+ }
114
+
115
+ // Check if a commit has a Change-Id in its message
116
+ export const commitHasChangeId = (commit: string = 'HEAD'): boolean => {
117
+ try {
118
+ const result = spawnSync('git', ['log', '-1', '--format=%B', commit], { encoding: 'utf8' })
119
+ if (result.status !== 0) {
120
+ return false
121
+ }
122
+ return CHANGE_ID_PATTERN.test(result.stdout)
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ // Get the hooks directory path
129
+ export const getHooksDir = (): string => {
130
+ const gitDir = getGitDir()
131
+ return path.join(gitDir, 'hooks')
132
+ }
133
+
134
+ // Service interface
135
+ export interface CommitHookServiceImpl {
136
+ readonly hasHook: () => Effect.Effect<boolean, NotGitRepoError>
137
+ readonly hasChangeId: (commit?: string) => Effect.Effect<boolean, NotGitRepoError>
138
+ readonly installHook: () => Effect.Effect<
139
+ void,
140
+ HookInstallError | NotGitRepoError,
141
+ ConfigServiceImpl
142
+ >
143
+ readonly ensureChangeId: () => Effect.Effect<
144
+ void,
145
+ HookInstallError | MissingChangeIdError | NotGitRepoError,
146
+ ConfigServiceImpl
147
+ >
148
+ readonly amendWithChangeId: () => Effect.Effect<void, HookInstallError | NotGitRepoError>
149
+ }
150
+
151
+ const CommitHookServiceImplLive: CommitHookServiceImpl = {
152
+ hasHook: () =>
153
+ Effect.try({
154
+ try: () => hasCommitMsgHook(),
155
+ catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
156
+ }),
157
+
158
+ hasChangeId: (commit = 'HEAD') =>
159
+ Effect.try({
160
+ try: () => commitHasChangeId(commit),
161
+ catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
162
+ }),
163
+
164
+ installHook: () =>
165
+ Effect.gen(function* () {
166
+ const configService = yield* ConfigService
167
+
168
+ // Get config to find Gerrit host
169
+ const config = yield* pipe(
170
+ configService.getCredentials,
171
+ Effect.mapError(
172
+ (e) => new HookInstallError({ message: `Failed to get config: ${e.message}` }),
173
+ ),
174
+ )
175
+
176
+ // Try to get hook via HTTP first (most reliable)
177
+ const normalizedHost = config.host.replace(/\/$/, '')
178
+ const hookUrl = `${normalizedHost}/tools/hooks/commit-msg`
179
+
180
+ yield* Console.log(`Installing commit-msg hook from ${config.host}...`)
181
+
182
+ const hookContent = yield* Effect.tryPromise({
183
+ try: async () => {
184
+ const response = await fetch(hookUrl)
185
+ if (!response.ok) {
186
+ throw new Error(`Failed to fetch hook: ${response.status} ${response.statusText}`)
187
+ }
188
+ return response.text()
189
+ },
190
+ catch: (error) =>
191
+ new HookInstallError({
192
+ message: `Failed to download commit-msg hook from ${hookUrl}: ${error}`,
193
+ cause: error,
194
+ }),
195
+ })
196
+
197
+ // Validate hook content (should be a shell script)
198
+ if (!hookContent.startsWith('#!')) {
199
+ yield* Effect.fail(
200
+ new HookInstallError({
201
+ message: 'Downloaded hook does not appear to be a valid script',
202
+ }),
203
+ )
204
+ }
205
+
206
+ // Get hooks directory and ensure it exists
207
+ const hooksDir = yield* Effect.try({
208
+ try: () => getHooksDir(),
209
+ catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
210
+ })
211
+
212
+ yield* Effect.try({
213
+ try: () => {
214
+ if (!fs.existsSync(hooksDir)) {
215
+ fs.mkdirSync(hooksDir, { recursive: true })
216
+ }
217
+ },
218
+ catch: (error) =>
219
+ new HookInstallError({
220
+ message: `Failed to create hooks directory: ${error}`,
221
+ cause: error,
222
+ }),
223
+ })
224
+
225
+ // Write hook file
226
+ const hookPath = path.join(hooksDir, 'commit-msg')
227
+
228
+ yield* Effect.try({
229
+ try: () => {
230
+ fs.writeFileSync(hookPath, hookContent, { mode: 0o755 })
231
+ },
232
+ catch: (error) =>
233
+ new HookInstallError({
234
+ message: `Failed to write commit-msg hook: ${error}`,
235
+ cause: error,
236
+ }),
237
+ })
238
+
239
+ yield* Console.log('commit-msg hook installed successfully')
240
+ }),
241
+
242
+ ensureChangeId: () =>
243
+ Effect.gen(function* () {
244
+ // Check if HEAD already has a Change-Id (using pure function directly)
245
+ if (commitHasChangeId()) {
246
+ return
247
+ }
248
+
249
+ // Check if hook is installed (using pure function directly)
250
+ if (!hasCommitMsgHook()) {
251
+ // Install hook and amend commit
252
+ yield* CommitHookServiceImplLive.installHook()
253
+ yield* CommitHookServiceImplLive.amendWithChangeId()
254
+ } else {
255
+ // Hook exists but commit doesn't have Change-Id
256
+ // This means the commit was created without the hook or hook failed
257
+ yield* Effect.fail(
258
+ new MissingChangeIdError({
259
+ message:
260
+ 'Commit is missing Change-Id. The commit-msg hook is installed but did not run.\n' +
261
+ 'Please amend your commit: git commit --amend',
262
+ }),
263
+ )
264
+ }
265
+ }),
266
+
267
+ amendWithChangeId: () =>
268
+ Effect.gen(function* () {
269
+ yield* Console.log('Amending commit to add Change-Id...')
270
+
271
+ yield* Effect.try({
272
+ try: () => {
273
+ // Use --no-edit to keep the same message, hook will add Change-Id
274
+ const result = spawnSync('git', ['commit', '--amend', '--no-edit'], {
275
+ encoding: 'utf8',
276
+ stdio: ['inherit', 'pipe', 'pipe'],
277
+ })
278
+
279
+ if (result.status !== 0) {
280
+ throw new Error(result.stderr || 'git commit --amend failed')
281
+ }
282
+ },
283
+ catch: (error) =>
284
+ new HookInstallError({
285
+ message: `Failed to amend commit: ${error}`,
286
+ cause: error,
287
+ }),
288
+ })
289
+
290
+ // Verify Change-Id was added
291
+ const hasId = commitHasChangeId()
292
+ if (!hasId) {
293
+ yield* Effect.fail(
294
+ new HookInstallError({
295
+ message: 'Failed to add Change-Id to commit. Hook may not be working correctly.',
296
+ }),
297
+ )
298
+ }
299
+
300
+ yield* Console.log('Change-Id added to commit')
301
+ }),
302
+ }
303
+
304
+ // Export service tag
305
+ export const CommitHookService: Context.Tag<CommitHookServiceImpl, CommitHookServiceImpl> =
306
+ Context.GenericTag<CommitHookServiceImpl>('CommitHookService')
307
+
308
+ export type CommitHookService = Context.Tag.Identifier<typeof CommitHookService>
309
+
310
+ // Export service layer
311
+ export const CommitHookServiceLive: Layer.Layer<CommitHookServiceImpl> = Layer.succeed(
312
+ CommitHookService,
313
+ CommitHookServiceImplLive,
314
+ )
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
+ import { Effect } from 'effect'
3
+ import * as fs from 'node:fs'
4
+ import * as os from 'node:os'
5
+ import * as path from 'node:path'
6
+ import { ConfigService, ConfigServiceLive } from './config'
7
+
8
+ describe('ConfigService', () => {
9
+ let originalEnv: NodeJS.ProcessEnv
10
+ const CONFIG_DIR = path.join(os.homedir(), '.ger')
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
12
+ let originalConfigContent: string | null = null
13
+
14
+ beforeEach(() => {
15
+ // Store original env vars
16
+ originalEnv = { ...process.env }
17
+
18
+ // Clear environment variables for clean tests
19
+ delete process.env.GERRIT_HOST
20
+ delete process.env.GERRIT_USERNAME
21
+ delete process.env.GERRIT_PASSWORD
22
+
23
+ // Backup and remove existing config file for clean tests
24
+ try {
25
+ if (fs.existsSync(CONFIG_FILE)) {
26
+ originalConfigContent = fs.readFileSync(CONFIG_FILE, 'utf8')
27
+ fs.unlinkSync(CONFIG_FILE)
28
+ }
29
+ } catch {
30
+ // Ignore errors
31
+ }
32
+ })
33
+
34
+ afterEach(() => {
35
+ // Restore original env vars
36
+ process.env = originalEnv
37
+
38
+ // Restore original config file
39
+ try {
40
+ if (originalConfigContent !== null) {
41
+ if (!fs.existsSync(CONFIG_DIR)) {
42
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
43
+ }
44
+ fs.writeFileSync(CONFIG_FILE, originalConfigContent, 'utf8')
45
+ fs.chmodSync(CONFIG_FILE, 0o600)
46
+ }
47
+ } catch {
48
+ // Ignore errors
49
+ }
50
+ originalConfigContent = null
51
+ })
52
+
53
+ describe('Environment Variable Configuration', () => {
54
+ test('loads config from environment variables when all required vars are present', async () => {
55
+ // Set environment variables
56
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
57
+ process.env.GERRIT_USERNAME = 'envuser'
58
+ process.env.GERRIT_PASSWORD = 'envpass123'
59
+
60
+ const result = await Effect.gen(function* () {
61
+ const configService = yield* ConfigService
62
+ return yield* configService.getFullConfig
63
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
64
+
65
+ expect(result).toEqual({
66
+ host: 'https://gerrit.example.com',
67
+ username: 'envuser',
68
+ password: 'envpass123',
69
+ aiAutoDetect: true,
70
+ aiTool: undefined,
71
+ })
72
+ })
73
+
74
+ test('loads credentials from environment variables', async () => {
75
+ // Set environment variables
76
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
77
+ process.env.GERRIT_USERNAME = 'envuser'
78
+ process.env.GERRIT_PASSWORD = 'envpass123'
79
+
80
+ const result = await Effect.gen(function* () {
81
+ const configService = yield* ConfigService
82
+ return yield* configService.getCredentials
83
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
84
+
85
+ expect(result).toEqual({
86
+ host: 'https://gerrit.example.com',
87
+ username: 'envuser',
88
+ password: 'envpass123',
89
+ })
90
+ })
91
+
92
+ test('fails when only some environment variables are present', async () => {
93
+ // Set only some environment variables
94
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
95
+ process.env.GERRIT_USERNAME = 'envuser'
96
+ // GERRIT_PASSWORD is missing
97
+
98
+ await expect(
99
+ Effect.gen(function* () {
100
+ const configService = yield* ConfigService
101
+ return yield* configService.getFullConfig
102
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
103
+ ).rejects.toThrow('Configuration not found')
104
+ })
105
+
106
+ test('validates environment variable configuration format', async () => {
107
+ // Set invalid environment variables
108
+ process.env.GERRIT_HOST = 'not-a-url'
109
+ process.env.GERRIT_USERNAME = 'envuser'
110
+ process.env.GERRIT_PASSWORD = 'envpass123'
111
+
112
+ await expect(
113
+ Effect.gen(function* () {
114
+ const configService = yield* ConfigService
115
+ return yield* configService.getFullConfig
116
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
117
+ ).rejects.toThrow('Invalid environment configuration format')
118
+ })
119
+
120
+ test('rejects empty environment variables', async () => {
121
+ // Set empty environment variables
122
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
123
+ process.env.GERRIT_USERNAME = ''
124
+ process.env.GERRIT_PASSWORD = 'envpass123'
125
+
126
+ await expect(
127
+ Effect.gen(function* () {
128
+ const configService = yield* ConfigService
129
+ return yield* configService.getFullConfig
130
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
131
+ ).rejects.toThrow('Configuration not found')
132
+ })
133
+
134
+ test('provides helpful error message when no configuration is found', async () => {
135
+ // Clear all relevant environment variables
136
+ delete process.env.GERRIT_HOST
137
+ delete process.env.GERRIT_USERNAME
138
+ delete process.env.GERRIT_PASSWORD
139
+
140
+ await expect(
141
+ Effect.gen(function* () {
142
+ const configService = yield* ConfigService
143
+ return yield* configService.getFullConfig
144
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
145
+ ).rejects.toThrow(
146
+ 'Configuration not found. Run "ger setup" to set up your credentials or set GERRIT_HOST, GERRIT_USERNAME, and GERRIT_PASSWORD environment variables.',
147
+ )
148
+ })
149
+ })
150
+ })