UNPKG

@aaronshaf/ger

Version:

Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS

656 lines (587 loc) 25.5 kB
import { Schema } from '@effect/schema' import { Context, Effect, Layer } from 'effect' import { ChangeInfo, CommentInfo, MessageInfo, type DiffOptions, FileDiffContent, FileInfo, type GerritCredentials, ProjectInfo, type ReviewInput, type ReviewerInput, ReviewerResult, RevisionInfo, SubmitInfo, GroupInfo, GroupDetailInfo, AccountInfo, } from '@/schemas/gerrit' import { ReviewerListItem } from '@/schemas/reviewer' import { filterMeaningfulMessages } from '@/utils/message-filters' import { convertToUnifiedDiff } from '@/utils/diff-formatters' import { ConfigService } from '@/services/config' import { normalizeChangeIdentifier } from '@/utils/change-id' export type { GerritApiServiceImpl, ApiErrorFields } from './gerrit-types' export { ApiError } from './gerrit-types' import { ApiError, type GerritApiServiceImpl } from './gerrit-types' export const GerritApiService: Context.Tag<GerritApiServiceImpl, GerritApiServiceImpl> = Context.GenericTag<GerritApiServiceImpl>('GerritApiService') export type GerritApiService = Context.Tag.Identifier<typeof GerritApiService> const createAuthHeader = (credentials: GerritCredentials): string => { const auth = btoa(`${credentials.username}:${credentials.password}`) return `Basic ${auth}` } const makeRequest = <T = unknown>( url: string, authHeader: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', body?: unknown, schema?: Schema.Schema<T>, ): Effect.Effect<T, ApiError> => Effect.gen(function* () { const headers: Record<string, string> = { Authorization: authHeader, } if (body) { headers['Content-Type'] = 'application/json' } const response = yield* Effect.tryPromise({ try: () => fetch(url, { method, headers, ...(method !== 'GET' && body ? { body: JSON.stringify(body) } : {}), }), catch: () => new ApiError({ message: 'Request failed - network or authentication error' }), }) if (!response.ok) { const errorText = yield* Effect.tryPromise({ try: () => response.text(), catch: () => 'Unknown error', }).pipe(Effect.orElseSucceed(() => 'Unknown error')) yield* Effect.fail(new ApiError({ message: errorText, status: response.status })) } const text = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new ApiError({ message: 'Failed to read response data' }), }) const cleanJson = text.replace(/^\)\]\}'\n?/, '') if (!cleanJson.trim()) return {} as T const parsed = yield* Effect.try({ try: () => JSON.parse(cleanJson), catch: () => new ApiError({ message: 'Failed to parse response - invalid JSON format' }), }) if (schema) { return yield* Schema.decodeUnknown(schema)(parsed).pipe( Effect.mapError(() => new ApiError({ message: 'Invalid response format from server' })), ) } return parsed }) export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigService> = Layer.effect( GerritApiService, Effect.gen(function* () { const configService = yield* ConfigService const getCredentialsAndAuth = Effect.gen(function* () { const credentials = yield* configService.getCredentials.pipe( Effect.mapError(() => new ApiError({ message: 'Failed to get credentials' })), ) const normalizedCredentials = { ...credentials, host: credentials.host.replace(/\/$/, '') } return { credentials: normalizedCredentials, authHeader: createAuthHeader(normalizedCredentials), } }) const normalizeAndValidate = (changeId: string): Effect.Effect<string, ApiError> => Effect.try({ try: () => normalizeChangeIdentifier(changeId), catch: (error) => new ApiError({ message: error instanceof Error ? error.message : String(error), }), }) const getChange = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=CURRENT_REVISION&o=CURRENT_COMMIT` return yield* makeRequest(url, authHeader, 'GET', undefined, ChangeInfo) }) const listChanges = (query = 'is:open') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const url = `${credentials.host}/a/changes/?q=${encodeURIComponent(query)}&o=LABELS&o=DETAILED_LABELS&o=DETAILED_ACCOUNTS&o=SUBMITTABLE&o=CURRENT_REVISION` return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(ChangeInfo)) }) const listProjects = (options?: { pattern?: string }) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth let url = `${credentials.host}/a/projects/` if (options?.pattern) url += `?p=${encodeURIComponent(options.pattern)}` const schema = Schema.Record({ key: Schema.String, value: ProjectInfo }) const projectsRecord = yield* makeRequest(url, authHeader, 'GET', undefined, schema) return Object.values(projectsRecord).sort((a, b) => a.name.localeCompare(b.name)) }) const postReview = (changeId: string, review: ReviewInput) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/review` yield* makeRequest(url, authHeader, 'POST', review) }) const abandonChange = (changeId: string, message?: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/abandon` const body = message ? { message } : {} yield* makeRequest(url, authHeader, 'POST', body) }) const restoreChange = (changeId: string, message?: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/restore` const body = message ? { message } : {} return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo) }) const rebaseChange = ( changeId: string, options?: { base?: string; allowConflicts?: boolean }, ) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/rebase` const body: Record<string, string | boolean> = {} if (options?.base) body['base'] = options.base if (options?.allowConflicts) body['allow_conflicts'] = true return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo) }) const submitChange = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/submit` return yield* makeRequest(url, authHeader, 'POST', {}, SubmitInfo) }) const testConnection = Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const url = `${credentials.host}/a/accounts/self` yield* makeRequest(url, authHeader) return true }).pipe( Effect.catchAll((error) => { if (process.env.DEBUG) { console.error('Connection error:', error) } return Effect.succeed(false) }), ) const getRevision = (changeId: string, revisionId = 'current') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}` return yield* makeRequest(url, authHeader, 'GET', undefined, RevisionInfo) }) const getFiles = (changeId: string, revisionId = 'current') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files` return yield* makeRequest( url, authHeader, 'GET', undefined, Schema.Record({ key: Schema.String, value: FileInfo }), ) }) const getFileDiff = ( changeId: string, filePath: string, revisionId = 'current', base?: string, ) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) let url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/diff` if (base) { url += `?base=${encodeURIComponent(base)}` } return yield* makeRequest(url, authHeader, 'GET', undefined, FileDiffContent) }) const getFileContent = (changeId: string, filePath: string, revisionId = 'current') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content` const response = yield* Effect.tryPromise({ try: () => fetch(url, { method: 'GET', headers: { Authorization: authHeader }, }), catch: () => new ApiError({ message: 'Request failed - network or authentication error' }), }) if (!response.ok) { const errorText = yield* Effect.tryPromise({ try: () => response.text(), catch: () => 'Unknown error', }).pipe(Effect.orElseSucceed(() => 'Unknown error')) yield* Effect.fail(new ApiError({ message: errorText, status: response.status })) } const base64Content = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new ApiError({ message: 'Failed to read response data' }), }) return yield* Effect.try({ try: () => atob(base64Content), catch: () => new ApiError({ message: 'Failed to decode file content' }), }) }) const getPatch = (changeId: string, revisionId = 'current') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/patch` const response = yield* Effect.tryPromise({ try: () => fetch(url, { method: 'GET', headers: { Authorization: authHeader }, }), catch: () => new ApiError({ message: 'Request failed - network or authentication error' }), }) if (!response.ok) { const errorText = yield* Effect.tryPromise({ try: () => response.text(), catch: () => 'Unknown error', }).pipe(Effect.orElseSucceed(() => 'Unknown error')) yield* Effect.fail(new ApiError({ message: errorText, status: response.status })) } const base64Patch = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new ApiError({ message: 'Failed to read response data' }), }) return yield* Effect.try({ try: () => atob(base64Patch), catch: () => new ApiError({ message: 'Failed to decode patch data' }), }) }) const getDiff = (changeId: string, options: DiffOptions = {}) => Effect.gen(function* () { const format = options.format || 'unified' const revisionId = options.patchset ? `${options.patchset}` : 'current' if (format === 'files') { const files = yield* getFiles(changeId, revisionId) return Object.keys(files) } if (options.file) { if (format === 'json') { const diff = yield* getFileDiff( changeId, options.file, revisionId, options.base ? `${options.base}` : undefined, ) return diff } else { const diff = yield* getFileDiff( changeId, options.file, revisionId, options.base ? `${options.base}` : undefined, ) return convertToUnifiedDiff(diff, options.file) } } if (options.fullFiles) { const files = yield* getFiles(changeId, revisionId) const result: Record<string, string> = {} for (const [filePath, _fileInfo] of Object.entries(files)) { if (filePath === '/COMMIT_MSG' || filePath === '/MERGE_LIST') continue const content = yield* getFileContent(changeId, filePath, revisionId).pipe( Effect.catchAll(() => Effect.succeed('Binary file or permission denied')), ) result[filePath] = content } return format === 'json' ? result : Object.entries(result) .map(([path, content]) => `=== ${path} ===\n${content}\n`) .join('\n') } if (format === 'json') { const files = yield* getFiles(changeId, revisionId) return files } return yield* getPatch(changeId, revisionId) }) const getComments = (changeId: string, revisionId = 'current') => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/${revisionId}/comments` return yield* makeRequest( url, authHeader, 'GET', undefined, Schema.Record({ key: Schema.String, value: Schema.Array(CommentInfo) }), ) }) const getMessages = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}?o=MESSAGES` const response = yield* makeRequest(url, authHeader, 'GET') const changeResponse = yield* Schema.decodeUnknown( Schema.Struct({ messages: Schema.optional(Schema.Array(MessageInfo)), }), )(response).pipe( Effect.mapError( () => new ApiError({ message: 'Invalid messages response format from server' }), ), ) return changeResponse.messages || [] }).pipe(Effect.map(filterMeaningfulMessages)) const addReviewer = ( changeId: string, reviewer: string, options?: { state?: 'REVIEWER' | 'CC' notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' }, ) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers` const body: ReviewerInput = { reviewer, ...(options?.state && { state: options.state }), ...(options?.notify && { notify: options.notify }), } return yield* makeRequest(url, authHeader, 'POST', body, ReviewerResult) }) const listGroups = (options?: { owned?: boolean project?: string user?: string pattern?: string limit?: number skip?: number }) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth let url = `${credentials.host}/a/groups/` const params: string[] = [] if (options?.owned) { params.push('owned') } if (options?.project) { params.push(`p=${encodeURIComponent(options.project)}`) } if (options?.user) { params.push(`user=${encodeURIComponent(options.user)}`) } if (options?.pattern) { params.push(`r=${encodeURIComponent(options.pattern)}`) } if (options?.limit) { params.push(`n=${options.limit}`) } if (options?.skip) { params.push(`S=${options.skip}`) } if (params.length > 0) { url += `?${params.join('&')}` } const groupsRecord = yield* makeRequest( url, authHeader, 'GET', undefined, Schema.Record({ key: Schema.String, value: GroupInfo }), ) return Object.values(groupsRecord).sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id), ) }) const getGroup = (groupId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}` return yield* makeRequest(url, authHeader, 'GET', undefined, GroupInfo) }) const getGroupDetail = (groupId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}/detail` return yield* makeRequest(url, authHeader, 'GET', undefined, GroupDetailInfo) }) const getGroupMembers = (groupId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const url = `${credentials.host}/a/groups/${encodeURIComponent(groupId)}/members/` return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(AccountInfo)) }) const getReviewers = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers` const schema = Schema.Array(ReviewerListItem) return yield* makeRequest(url, authHeader, 'GET', undefined, schema) }) const removeReviewer = ( changeId: string, accountId: string, options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' }, ) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers/${encodeURIComponent(accountId)}/delete` const body = options?.notify ? { notify: options.notify } : {} yield* makeRequest(url, authHeader, 'POST', body) }) const getTopicUrl = (host: string, changeId: string): string => `${host}/a/changes/${encodeURIComponent(changeId)}/topic` const getTopic = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) return yield* makeRequest( getTopicUrl(credentials.host, normalized), authHeader, 'GET', undefined, Schema.String, ).pipe( Effect.map((t) => t.replace(/^"|"$/g, '') || null), Effect.catchIf( (e) => e instanceof ApiError && e.status === 404, () => Effect.succeed(null), ), ) }) const setTopic = (changeId: string, topic: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const result = yield* makeRequest( getTopicUrl(credentials.host, normalized), authHeader, 'PUT', { topic }, Schema.String, ) return result.replace(/^"|"$/g, '') }) const deleteTopic = (changeId: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) yield* makeRequest(getTopicUrl(credentials.host, normalized), authHeader, 'DELETE') }) const setReady = (changeId: string, message?: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/ready` const body = message ? { message } : {} yield* makeRequest(url, authHeader, 'POST', body) }) const setWip = (changeId: string, message?: string) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const normalized = yield* normalizeAndValidate(changeId) const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/wip` const body = message ? { message } : {} yield* makeRequest(url, authHeader, 'POST', body) }) const fetchMergedChanges = (options: { after: string before?: string repo?: string maxResults?: number }) => Effect.gen(function* () { const { credentials, authHeader } = yield* getCredentialsAndAuth const limit = options.maxResults ?? 500 const pageSize = Math.min(limit, 500) const allChanges: ChangeInfo[] = [] let start = 0 let hasMore = true while (hasMore) { let q = `status:merged after:${options.after}` if (options.before) q += ` before:${options.before}` if (options.repo) q += ` project:${options.repo}` const url = `${credentials.host}/a/changes/?q=${encodeURIComponent(q)}&o=DETAILED_ACCOUNTS&n=${pageSize}&S=${start}` const page = yield* makeRequest( url, authHeader, 'GET', undefined, Schema.Array(ChangeInfo), ) allChanges.push(...page) const remaining = limit - allChanges.length if (page.length < pageSize || remaining <= 0) { hasMore = false } else { start += pageSize } } if (allChanges.length >= limit) { console.warn( `Warning: results capped at ${limit}. Use --start-date to narrow the date range.`, ) } return allChanges as readonly ChangeInfo[] }) return { getChange, listChanges, listProjects, postReview, abandonChange, restoreChange, rebaseChange, submitChange, testConnection, getRevision, getFiles, getFileDiff, getFileContent, getPatch, getDiff, getComments, getMessages, addReviewer, getReviewers, listGroups, getGroup, getGroupDetail, getGroupMembers, removeReviewer, getTopic, setTopic, deleteTopic, setReady, setWip, fetchMergedChanges, } }), )