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