@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,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
|
+
})
|