@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
189 lines (168 loc) • 6.73 kB
text/typescript
import { Effect } from 'effect'
import { type ApiError, GerritApiService } from '@/api/gerrit'
interface AddReviewerOptions {
change?: string
cc?: boolean
notify?: string
xml?: boolean
json?: boolean
group?: boolean
}
type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
const escapeXml = (str: string): string =>
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
const outputXmlError = (message: string): void => {
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
console.log(`<add_reviewer_result>`)
console.log(` <status>error</status>`)
console.log(` <error><![CDATA[${message}]]></error>`)
console.log(`</add_reviewer_result>`)
}
const outputJsonError = (message: string): void => {
console.log(JSON.stringify({ status: 'error', error: message }, null, 2))
}
class ValidationError extends Error {
readonly _tag = 'ValidationError'
}
export const addReviewerCommand = (
reviewers: string[],
options: AddReviewerOptions = {},
): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
Effect.gen(function* () {
const gerritApi = yield* GerritApiService
const changeId = options.change
if (!changeId) {
const message =
'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
if (options.json) {
outputJsonError(message)
} else if (options.xml) {
outputXmlError(message)
} else {
console.error(`✗ ${message}`)
}
return yield* Effect.fail(new ValidationError(message))
}
if (reviewers.length === 0) {
const entityType = options.group ? 'group' : 'reviewer'
const message = `At least one ${entityType} is required.`
if (options.json) {
outputJsonError(message)
} else if (options.xml) {
outputXmlError(message)
} else {
console.error(`✗ ${message}`)
}
return yield* Effect.fail(new ValidationError(message))
}
// Validate that email-like inputs aren't used with --group flag
// Note: This uses a simple heuristic (presence of '@') to detect likely email addresses.
// While Gerrit group names could theoretically contain '@', this is rare in practice
// and the validation serves as a helpful UX guardrail against common mistakes.
if (options.group) {
const emailLikeInputs = reviewers.filter((r) => r.includes('@'))
if (emailLikeInputs.length > 0) {
const message = `The --group flag expects group identifiers, but received email-like input: ${emailLikeInputs.join(', ')}. Did you mean to omit --group?`
if (options.json) {
outputJsonError(message)
} else if (options.xml) {
outputXmlError(message)
} else {
console.error(`✗ ${message}`)
}
return yield* Effect.fail(new ValidationError(message))
}
}
const state: 'REVIEWER' | 'CC' = options.cc ? 'CC' : 'REVIEWER'
const entityType = options.group ? 'group' : 'individual'
const stateLabel = options.cc ? 'cc' : options.group ? 'group' : 'reviewer'
let notify: NotifyLevel | undefined
if (options.notify) {
const upperNotify = options.notify.toUpperCase()
if (!VALID_NOTIFY_LEVELS.includes(upperNotify as NotifyLevel)) {
const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
if (options.json) {
outputJsonError(message)
} else if (options.xml) {
outputXmlError(message)
} else {
console.error(`✗ ${message}`)
}
return yield* Effect.fail(new ValidationError(message))
}
notify = upperNotify as NotifyLevel
}
const results: Array<{ reviewer: string; success: boolean; name?: string; error?: string }> = []
for (const reviewer of reviewers) {
const result = yield* Effect.either(
gerritApi.addReviewer(changeId, reviewer, { state, notify }),
)
if (result._tag === 'Left') {
const error = result.left
const message = 'message' in error ? String(error.message) : String(error)
results.push({ reviewer, success: false, error: message })
continue
}
const apiResult = result.right
if (apiResult.error) {
results.push({ reviewer, success: false, error: apiResult.error })
} else {
const added = apiResult.reviewers?.[0] || apiResult.ccs?.[0]
const name = added?.name || added?.email || reviewer
results.push({ reviewer, success: true, name })
}
}
if (options.json) {
const allSuccess = results.every((r) => r.success)
console.log(
JSON.stringify(
{
status: allSuccess ? 'success' : 'partial_failure',
change_id: changeId,
state,
entity_type: entityType,
reviewers: results.map((r) =>
r.success
? { input: r.reviewer, name: r.name, status: 'added' }
: { input: r.reviewer, error: r.error, status: 'failed' },
),
},
null,
2,
),
)
} else if (options.xml) {
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
console.log(`<add_reviewer_result>`)
console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
console.log(` <state>${escapeXml(state)}</state>`)
console.log(` <entity_type>${escapeXml(entityType)}</entity_type>`)
console.log(` <reviewers>`)
for (const r of results) {
if (r.success) {
console.log(` <reviewer status="added">`)
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
console.log(` <name><![CDATA[${r.name}]]></name>`)
console.log(` </reviewer>`)
} else {
console.log(` <reviewer status="failed">`)
console.log(` <input>${escapeXml(r.reviewer)}</input>`)
console.log(` <error><![CDATA[${r.error}]]></error>`)
console.log(` </reviewer>`)
}
}
console.log(` </reviewers>`)
const allSuccess = results.every((r) => r.success)
console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
console.log(`</add_reviewer_result>`)
} else {
for (const r of results) {
if (r.success) {
console.log(`✓ Added ${r.name} as ${stateLabel}`)
} else {
console.error(`✗ Failed to add ${r.reviewer}: ${r.error}`)
}
}
}
})