@aaronshaf/ger 0.1.11 → 0.2.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.
@@ -2,77 +2,82 @@ name: Claude Code Review
2
2
 
3
3
  on:
4
4
  pull_request:
5
- types: [opened, synchronize]
6
- # Optional: Only run on specific file changes
7
- # paths:
8
- # - "src/**/*.ts"
9
- # - "src/**/*.tsx"
10
- # - "src/**/*.js"
11
- # - "src/**/*.jsx"
5
+ types: [opened, synchronize, ready_for_review, reopened]
6
+ paths:
7
+ - "src/**/*.ts"
8
+ - "tests/**/*.ts"
9
+ - "scripts/**/*.ts"
10
+ - "package.json"
11
+ - "tsconfig.json"
12
12
 
13
13
  jobs:
14
14
  claude-review:
15
- # Optional: Filter by PR author
16
- # if: |
17
- # github.event.pull_request.user.login == 'external-contributor' ||
18
- # github.event.pull_request.user.login == 'new-developer' ||
19
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20
-
15
+ # Skip draft PRs and PRs from bots
16
+ if: |
17
+ github.event.pull_request.draft == false &&
18
+ github.event.pull_request.user.login != 'dependabot[bot]'
19
+
21
20
  runs-on: ubuntu-latest
22
21
  permissions:
23
22
  contents: read
24
- pull-requests: read
25
- issues: read
23
+ pull-requests: write
26
24
  id-token: write
27
-
25
+
28
26
  steps:
29
27
  - name: Checkout repository
30
- uses: actions/checkout@v4
28
+ uses: actions/checkout@v5
31
29
  with:
32
30
  fetch-depth: 1
33
31
 
34
32
  - name: Run Claude Code Review
35
33
  id: claude-review
36
- uses: anthropics/claude-code-action@beta
34
+ uses: anthropics/claude-code-action@v1
37
35
  with:
38
36
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
37
 
40
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
41
- # model: "claude-opus-4-20250514"
42
-
43
- # Direct prompt for automated review (no @claude mention needed)
44
- direct_prompt: |
45
- Please review this pull request and provide feedback on:
46
- - Code quality and best practices
47
- - Potential bugs or issues
48
- - Performance considerations
49
- - Security concerns
50
- - Test coverage
51
-
52
- Be constructive and helpful in your feedback.
53
-
54
- # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
55
- # use_sticky_comment: true
56
-
57
- # Optional: Customize review based on file types
58
- # direct_prompt: |
59
- # Review this PR focusing on:
60
- # - For TypeScript files: Type safety and proper interface usage
61
- # - For API endpoints: Security, input validation, and error handling
62
- # - For React components: Performance, accessibility, and best practices
63
- # - For tests: Coverage, edge cases, and test quality
64
-
65
- # Optional: Different prompts for different authors
66
- # direct_prompt: |
67
- # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
68
- # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
69
- # 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
70
-
71
- # Optional: Add specific tools for running tests or linting
72
- # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
73
-
74
- # Optional: Skip review for certain conditions
75
- # if: |
76
- # !contains(github.event.pull_request.title, '[skip-review]') &&
77
- # !contains(github.event.pull_request.title, '[WIP]')
38
+ # Enable progress tracking for sticky comments
39
+ track_progress: true
40
+
41
+ prompt: |
42
+ REPO: ${{ github.repository }}
43
+ PR NUMBER: ${{ github.event.pull_request.number }}
44
+
45
+ Perform a comprehensive code review focusing on:
46
+
47
+ 1. **Code Quality & Standards**
48
+ - Adherence to CLAUDE.md project rules
49
+ - TypeScript best practices with isolatedDeclarations
50
+ - No implicit any or as typecasting (except as const/as unknown)
51
+ - Functional programming patterns with Effect
52
+ - Files should not exceed 700 lines (block) or 500 lines (warn)
53
+
54
+ 2. **Testing & Coverage**
55
+ - Minimum 80% code coverage requirement
56
+ - Both unit tests and integration tests for command changes
57
+ - Proper HTTP mocking with Bun's native fetch
58
+ - Effect Schema validation in tests
59
+
60
+ 3. **Security**
61
+ - No sensitive data in code or error messages
62
+ - Effect Schema validation for all inputs
63
+ - SQL injection prevention
64
+
65
+ 4. **Architecture & Patterns**
66
+ - Effect services implementation
67
+ - Cache-first strategy with SQLite
68
+ - Regional error boundaries
69
+ - Proper i18n with i18next
70
+
71
+ 5. **Performance**
72
+ - Efficient caching strategies
73
+ - Minimized API calls
74
+ - Bundle size optimization
75
+
76
+ Provide detailed feedback using inline comments for specific issues.
77
+ Use top-level comments for general observations.
78
+ Reference file:line_number for all findings.
79
+
80
+ # Tools for comprehensive PR review with inline comments
81
+ claude_args: |
82
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"
78
83
 
@@ -32,33 +32,19 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@beta
35
+ uses: anthropics/claude-code-action@v1
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
-
38
+
39
39
  # This is an optional setting that allows Claude to read CI results on PRs
40
40
  additional_permissions: |
41
41
  actions: read
42
-
43
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
44
- # model: "claude-opus-4-20250514"
45
-
46
- # Optional: Customize the trigger phrase (default: @claude)
47
- # trigger_phrase: "/claude"
48
-
49
- # Optional: Trigger when specific user is assigned to an issue
50
- # assignee_trigger: "claude-bot"
51
-
52
- # Optional: Allow Claude to run specific commands
53
- # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
54
-
55
- # Optional: Add custom instructions for Claude to customize its behavior for your project
56
- # custom_instructions: |
57
- # Follow our coding standards
58
- # Ensure all new code has tests
59
- # Use TypeScript for new files
60
-
61
- # Optional: Custom environment variables for Claude
62
- # claude_env: |
63
- # NODE_ENV: test
42
+
43
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
44
+ # prompt: 'Update the pull request description to include a summary of changes.'
45
+
46
+ # Optional: Add claude_args to customize behavior and configuration
47
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
48
+ # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
49
+ # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
64
50
 
package/README.md CHANGED
@@ -52,6 +52,9 @@ ger comment 12345 -m "LGTM"
52
52
  # Get diff for review
53
53
  ger diff 12345
54
54
 
55
+ # Extract URLs from messages (e.g., Jenkins build links)
56
+ ger extract-url "build-summary-report" | tail -1
57
+
55
58
  # AI-powered code review (requires claude, llm, or opencode CLI)
56
59
  ger review 12345
57
60
  ger review 12345 --dry-run # Preview without posting
@@ -191,6 +194,54 @@ ger comments 12345
191
194
  ger comments 12345 --pretty
192
195
  ```
193
196
 
197
+ ### Extract URLs
198
+
199
+ Extract URLs from change messages and comments for automation and scripting:
200
+
201
+ ```bash
202
+ # Extract all Jenkins build-summary-report URLs
203
+ ger extract-url "build-summary-report"
204
+
205
+ # Get the latest build URL (using tail)
206
+ ger extract-url "build-summary-report" | tail -1
207
+
208
+ # Get the first/oldest build URL (using head)
209
+ ger extract-url "jenkins" | head -1
210
+
211
+ # For a specific change
212
+ ger extract-url "build-summary" 12345
213
+
214
+ # Use regex for precise matching
215
+ ger extract-url "job/Canvas/job/main/\d+/" --regex
216
+
217
+ # Search both messages and inline comments
218
+ ger extract-url "github.com" --include-comments
219
+
220
+ # JSON output for scripting
221
+ ger extract-url "jenkins" --json | jq -r '.urls[-1]'
222
+
223
+ # XML output
224
+ ger extract-url "jenkins" --xml
225
+ ```
226
+
227
+ #### How it works:
228
+ - **Pattern matching**: Substring match by default, regex with `--regex`
229
+ - **Sources**: Searches messages by default, add `--include-comments` to include inline comments
230
+ - **Ordering**: URLs are output in chronological order (oldest first)
231
+ - **Composable**: Pipe to `tail -1` for latest, `head -1` for oldest
232
+
233
+ #### Common use cases:
234
+ ```bash
235
+ # Get latest Jenkins build URL for a change
236
+ ger extract-url "jenkins.inst-ci.net" | tail -1
237
+
238
+ # Find all GitHub PR references
239
+ ger extract-url "github.com" --include-comments
240
+
241
+ # Extract specific build job URLs with regex
242
+ ger extract-url "job/[^/]+/job/[^/]+/\d+/$" --regex
243
+ ```
244
+
194
245
  ### Diff
195
246
  ```bash
196
247
  # Full diff
package/bun.lock CHANGED
@@ -4,25 +4,25 @@
4
4
  "": {
5
5
  "name": "ger",
6
6
  "dependencies": {
7
- "@effect/platform": "^0.90.6",
7
+ "@effect/platform": "^0.90.10",
8
8
  "@effect/platform-node": "^0.94.2",
9
9
  "@effect/schema": "^0.75.5",
10
10
  "@inquirer/prompts": "^7.8.4",
11
11
  "chalk": "^5.6.0",
12
12
  "cli-table3": "^0.6.5",
13
13
  "commander": "^14.0.0",
14
- "effect": "^3.17.9",
14
+ "effect": "^3.18.4",
15
15
  "signal-exit": "3.0.7",
16
16
  },
17
17
  "devDependencies": {
18
18
  "@biomejs/biome": "^2.2.2",
19
- "@types/node": "^24.3.0",
19
+ "@types/node": "^24.3.1",
20
20
  "ast-grep": "^0.1.0",
21
21
  "bun-types": "^1.2.21",
22
22
  "husky": "^9.1.7",
23
- "lint-staged": "^16.1.5",
24
- "msw": "^2.10.5",
25
- "oxlint": "^1.13.0",
23
+ "lint-staged": "^16.1.6",
24
+ "msw": "^2.11.1",
25
+ "oxlint": "^1.14.0",
26
26
  "typescript": "^5.9.2",
27
27
  },
28
28
  "peerDependencies": {
@@ -77,7 +77,7 @@
77
77
 
78
78
  "@effect/experimental": ["@effect/experimental@0.54.6", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.90.2", "effect": "^3.17.7", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-UqHMvCQmrZT6kUVoUC0lqyno4Yad+j9hBGCdUjW84zkLwAq08tPqySiZUKRwY+Ae5B2Ab8rISYJH7nQvct9DMQ=="],
79
79
 
80
- "@effect/platform": ["@effect/platform@0.90.6", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.17.8" } }, "sha512-aT7aLJR1+rYrSLdw5af2UZzwnWoAy8WmkTxTUD3pFY6vjFmh+8137RhbwKiWjIJBTm2DVyPXl1dx1kGg28xt6Q=="],
80
+ "@effect/platform": ["@effect/platform@0.90.10", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.17.13" } }, "sha512-QhDPgCaLfIMQKOCoCPQvRUS+Y34iYJ07jdZ/CBAvYFvg/iUBebsmFuHL63RCD/YZH9BuK/kqqLYAA3M0fmUEgg=="],
81
81
 
82
82
  "@effect/platform-node": ["@effect/platform-node@0.94.2", "", { "dependencies": { "@effect/platform-node-shared": "^0.47.2", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.46.4", "@effect/platform": "^0.90.0", "@effect/rpc": "^0.68.3", "@effect/sql": "^0.44.1", "effect": "^3.17.6" } }, "sha512-iI7vUjNqd1DOFCa/9Tyf6Cu00Y4oLKMrpa2lx8+bUIHxtYbk696Yd9VFIDLMXVWrKFUru4Fw7WgWaA/YDor/sw=="],
83
83
 
@@ -283,7 +283,7 @@
283
283
 
284
284
  "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
285
285
 
286
- "effect": ["effect@3.17.13", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-JMz5oBxs/6mu4FP9Csjub4jYMUwMLrp+IzUmSDVIzn2NoeoyOXMl7x1lghfr3dLKWffWrdnv/d8nFFdgrHXPqw=="],
286
+ "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
287
287
 
288
288
  "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
289
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,14 +29,14 @@
29
29
  "typescript": "^5.0.0"
30
30
  },
31
31
  "dependencies": {
32
- "@effect/platform": "^0.90.6",
32
+ "@effect/platform": "^0.90.10",
33
33
  "@effect/platform-node": "^0.94.2",
34
34
  "@effect/schema": "^0.75.5",
35
35
  "@inquirer/prompts": "^7.8.4",
36
36
  "chalk": "^5.6.0",
37
37
  "cli-table3": "^0.6.5",
38
38
  "commander": "^14.0.0",
39
- "effect": "^3.17.13",
39
+ "effect": "^3.18.4",
40
40
  "signal-exit": "3.0.7"
41
41
  },
42
42
  "scripts": {
package/src/api/gerrit.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from '@/schemas/gerrit'
14
14
  import { filterMeaningfulMessages } from '@/utils/message-filters'
15
15
  import { ConfigService } from '@/services/config'
16
+ import { normalizeChangeIdentifier } from '@/utils/change-id'
16
17
 
17
18
  export interface GerritApiServiceImpl {
18
19
  readonly getChange: (changeId: string) => Effect.Effect<ChangeInfo, ApiError>
@@ -51,15 +52,32 @@ export interface GerritApiServiceImpl {
51
52
  readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
52
53
  }
53
54
 
54
- export class GerritApiService extends Context.Tag('GerritApiService')<
55
- GerritApiService,
56
- GerritApiServiceImpl
57
- >() {}
55
+ // Export both the tag value and the type for use in Effect requirements
56
+ export const GerritApiService: Context.Tag<GerritApiServiceImpl, GerritApiServiceImpl> =
57
+ Context.GenericTag<GerritApiServiceImpl>('GerritApiService')
58
+ export type GerritApiService = Context.Tag.Identifier<typeof GerritApiService>
58
59
 
59
- export class ApiError extends Schema.TaggedError<ApiError>()('ApiError', {
60
+ // Export ApiError fields interface explicitly
61
+ export interface ApiErrorFields {
62
+ readonly message: string
63
+ readonly status?: number
64
+ }
65
+
66
+ // Define error schema (not exported, so type can be implicit)
67
+ const ApiErrorSchema = Schema.TaggedError<ApiErrorFields>()('ApiError', {
60
68
  message: Schema.String,
61
69
  status: Schema.optional(Schema.Number),
62
- } as const) {}
70
+ } as const) as unknown
71
+
72
+ // Export the error class with explicit constructor signature for isolatedDeclarations
73
+ export class ApiError
74
+ extends (ApiErrorSchema as new (
75
+ args: ApiErrorFields,
76
+ ) => ApiErrorFields & Error & { readonly _tag: 'ApiError' })
77
+ implements Error
78
+ {
79
+ readonly name = 'ApiError'
80
+ }
63
81
 
64
82
  const createAuthHeader = (credentials: GerritCredentials): string => {
65
83
  const auth = btoa(`${credentials.username}:${credentials.password}`)
@@ -150,10 +168,21 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
150
168
  return { credentials: normalizedCredentials, authHeader }
151
169
  })
152
170
 
171
+ // Helper to normalize and validate change identifier
172
+ const normalizeAndValidate = (changeId: string): Effect.Effect<string, ApiError> =>
173
+ Effect.try({
174
+ try: () => normalizeChangeIdentifier(changeId),
175
+ catch: (error) =>
176
+ new ApiError({
177
+ message: error instanceof Error ? error.message : String(error),
178
+ }),
179
+ })
180
+
153
181
  const getChange = (changeId: string) =>
154
182
  Effect.gen(function* () {
155
183
  const { credentials, authHeader } = yield* getCredentialsAndAuth
156
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}`
184
+ const normalized = yield* normalizeAndValidate(changeId)
185
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}`
157
186
  return yield* makeRequest(url, authHeader, 'GET', undefined, ChangeInfo)
158
187
  })
159
188
 
@@ -169,14 +198,16 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
169
198
  const postReview = (changeId: string, review: ReviewInput) =>
170
199
  Effect.gen(function* () {
171
200
  const { credentials, authHeader } = yield* getCredentialsAndAuth
172
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/current/review`
201
+ const normalized = yield* normalizeAndValidate(changeId)
202
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/review`
173
203
  yield* makeRequest(url, authHeader, 'POST', review)
174
204
  })
175
205
 
176
206
  const abandonChange = (changeId: string, message?: string) =>
177
207
  Effect.gen(function* () {
178
208
  const { credentials, authHeader } = yield* getCredentialsAndAuth
179
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/abandon`
209
+ const normalized = yield* normalizeAndValidate(changeId)
210
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/abandon`
180
211
  const body = message ? { message } : {}
181
212
  yield* makeRequest(url, authHeader, 'POST', body)
182
213
  })
@@ -199,14 +230,16 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
199
230
  const getRevision = (changeId: string, revisionId = 'current') =>
200
231
  Effect.gen(function* () {
201
232
  const { credentials, authHeader } = yield* getCredentialsAndAuth
202
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}`
233
+ const normalized = yield* normalizeAndValidate(changeId)
234
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}`
203
235
  return yield* makeRequest(url, authHeader, 'GET', undefined, RevisionInfo)
204
236
  })
205
237
 
206
238
  const getFiles = (changeId: string, revisionId = 'current') =>
207
239
  Effect.gen(function* () {
208
240
  const { credentials, authHeader } = yield* getCredentialsAndAuth
209
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files`
241
+ const normalized = yield* normalizeAndValidate(changeId)
242
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files`
210
243
  return yield* makeRequest(
211
244
  url,
212
245
  authHeader,
@@ -224,7 +257,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
224
257
  ) =>
225
258
  Effect.gen(function* () {
226
259
  const { credentials, authHeader } = yield* getCredentialsAndAuth
227
- let url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/diff`
260
+ const normalized = yield* normalizeAndValidate(changeId)
261
+ let url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/diff`
228
262
  if (base) {
229
263
  url += `?base=${encodeURIComponent(base)}`
230
264
  }
@@ -234,7 +268,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
234
268
  const getFileContent = (changeId: string, filePath: string, revisionId = 'current') =>
235
269
  Effect.gen(function* () {
236
270
  const { credentials, authHeader } = yield* getCredentialsAndAuth
237
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content`
271
+ const normalized = yield* normalizeAndValidate(changeId)
272
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content`
238
273
 
239
274
  const response = yield* Effect.tryPromise({
240
275
  try: () =>
@@ -274,7 +309,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
274
309
  const getPatch = (changeId: string, revisionId = 'current') =>
275
310
  Effect.gen(function* () {
276
311
  const { credentials, authHeader } = yield* getCredentialsAndAuth
277
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/patch`
312
+ const normalized = yield* normalizeAndValidate(changeId)
313
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/patch`
278
314
 
279
315
  const response = yield* Effect.tryPromise({
280
316
  try: () =>
@@ -417,7 +453,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
417
453
  const getComments = (changeId: string, revisionId = 'current') =>
418
454
  Effect.gen(function* () {
419
455
  const { credentials, authHeader } = yield* getCredentialsAndAuth
420
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/comments`
456
+ const normalized = yield* normalizeAndValidate(changeId)
457
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/comments`
421
458
  return yield* makeRequest(
422
459
  url,
423
460
  authHeader,
@@ -430,7 +467,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
430
467
  const getMessages = (changeId: string) =>
431
468
  Effect.gen(function* () {
432
469
  const { credentials, authHeader } = yield* getCredentialsAndAuth
433
- const url = `${credentials.host}/a/changes/${encodeURIComponent(changeId)}?o=MESSAGES`
470
+ const normalized = yield* normalizeAndValidate(changeId)
471
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=MESSAGES`
434
472
  const response = yield* makeRequest(url, authHeader, 'GET')
435
473
 
436
474
  // Extract messages from the change response with runtime validation