@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
566 lines (508 loc) • 19.2 kB
text/typescript
import { Schema, ParseResult, TreeFormatter } from '@effect/schema'
import { Effect, pipe } from 'effect'
import { type ApiError, GerritApiService } from '@/api/gerrit'
import type { ChangeInfo, ReviewInput } from '@/schemas/gerrit'
export const COMMENT_HELP_TEXT = `
Examples:
# Post a general comment on a change
$ ger comment 12345 -m "Looks good to me!"
# Post a comment using piped input
$ echo "This is a comment from stdin!" | ger comment 12345
# Post a line-specific comment
$ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
# Reply to a specific comment thread (resolves the thread by default)
$ ger comment 12345 --file src/main.js --line 42 --reply-to 37935b71_9e79a76c -m "Done, fixed"
# Reply but keep the thread unresolved
$ ger comment 12345 --file src/main.js --line 42 --reply-to 37935b71_9e79a76c --unresolved -m "What do you think?"
# Post multiple comments using batch mode
$ echo '{"message": "Review complete", "comments": [
{"file": "src/main.js", "line": 10, "message": "Good refactor"}
]}' | ger comment 12345 --batch
Note: Line numbers refer to the NEW version of the file, not diff line numbers.
Note: Comment IDs for --reply-to can be found in \`ger comments --xml\` or \`ger comments --json\` output (<id> / "id" field).`
interface CommentOptions {
message?: string
xml?: boolean
json?: boolean
file?: string
line?: number
replyTo?: string
unresolved?: boolean
batch?: boolean
}
// Schema for batch input validation - array of comments
const BatchCommentSchema = Schema.Array(
Schema.Struct({
file: Schema.String,
line: Schema.optional(Schema.Number), // Optional when using range
range: Schema.optional(
Schema.Struct({
start_line: Schema.Number,
end_line: Schema.Number,
start_character: Schema.optional(Schema.Number),
end_character: Schema.optional(Schema.Number),
}),
),
message: Schema.String,
path: Schema.optional(Schema.String), // Support both 'file' and 'path' for flexibility
side: Schema.optional(Schema.Literal('PARENT', 'REVISION')),
unresolved: Schema.optional(Schema.Boolean),
}),
)
type BatchCommentInput = Schema.Schema.Type<typeof BatchCommentSchema>
// Effect-ified stdin reader
const readStdin = Effect.async<string, Error>((callback) => {
let data = ''
const onData = (chunk: Buffer | string) => {
data += chunk
}
const onEnd = () => callback(Effect.succeed(data))
const onError = (error: Error) =>
callback(Effect.fail(new Error(`Failed to read stdin: ${error.message}`)))
process.stdin.on('data', onData)
process.stdin.on('end', onEnd)
process.stdin.on('error', onError)
// Cleanup function
return Effect.sync(() => {
process.stdin.removeListener('data', onData)
process.stdin.removeListener('end', onEnd)
process.stdin.removeListener('error', onError)
})
})
// Helper to parse JSON with better error handling
const parseJson = (data: string): Effect.Effect<unknown, Error> =>
Effect.try({
try: () => JSON.parse(data),
catch: (error) => {
const errorMsg = error instanceof Error ? error.message : 'parse error'
const lines = data.split('\n')
const lineCount = lines.length
// Show first few lines to help identify the issue
const preview = lines.slice(0, 10).join('\n')
const truncated = lineCount > 10 ? `\n... (${lineCount - 10} more lines)` : ''
return new Error(
`Invalid JSON input: ${errorMsg}\n` +
`Input (${data.length} chars, ${lineCount} lines):\n` +
`${preview}${truncated}\n\n` +
`Expected format: [{"file": "path/to/file", "line": 123, "message": "comment text"}]`,
)
},
})
// Helper to build ReviewInput from batch data
const buildBatchReview = (batchInput: BatchCommentInput): ReviewInput => {
const commentsByFile = batchInput.reduce<
Record<
string,
Array<{
line?: number
range?: {
start_line: number
end_line: number
start_character?: number
end_character?: number
}
message: string
side?: 'PARENT' | 'REVISION'
unresolved?: boolean
}>
>
>((acc, comment) => {
// Support both 'file' and 'path' properties
const filePath = comment.file || comment.path || ''
if (filePath && !acc[filePath]) {
acc[filePath] = []
}
if (filePath) {
// When range is present, don't include line (Gerrit API preference)
const commentObj: any = {
message: comment.message,
side: comment.side,
unresolved: comment.unresolved,
}
if (comment.range) {
commentObj.range = comment.range
} else if (comment.line) {
commentObj.line = comment.line
}
acc[filePath].push(commentObj)
}
return acc
}, {})
return {
comments: commentsByFile,
}
}
// Create ReviewInput based on options
export const createReviewInputFromString = (
content: string,
options: CommentOptions,
): Effect.Effect<ReviewInput, Error> => {
// Batch mode with provided content
if (options.batch) {
return pipe(
parseJson(content),
Effect.flatMap(
Schema.decodeUnknown(BatchCommentSchema, {
errors: 'all',
onExcessProperty: 'ignore',
}),
),
Effect.mapError((error) => {
let errorMessage = 'Invalid batch input format.\n'
if (ParseResult.isParseError(error)) {
errorMessage += TreeFormatter.formatErrorSync(error)
errorMessage += '\n\nExpected format: [{"file": "...", "line": ..., "message": "..."}]'
} else if (error instanceof Error) {
errorMessage += error.message
} else {
errorMessage +=
'Expected: [{"file": "...", "line": ..., "message": "...", "side"?: "PARENT|REVISION", "range"?: {...}}]'
}
return new Error(errorMessage)
}),
Effect.map(buildBatchReview),
)
}
// Overall comment with provided content
const message = content.trim()
return message.length > 0
? Effect.succeed({ message })
: Effect.fail(new Error('Message is required'))
}
const createReviewInput = (options: CommentOptions): Effect.Effect<ReviewInput, Error> => {
// Validate --reply-to constraints early
if (options.replyTo !== undefined) {
if (options.batch) {
return Effect.fail(new Error('--reply-to cannot be used with --batch'))
}
if (!(options.file && options.line)) {
return Effect.fail(new Error('--reply-to requires --file and --line'))
}
if (options.replyTo.trim().length === 0) {
return Effect.fail(new Error('--reply-to comment ID cannot be empty'))
}
// Normalize to trimmed value so the payload never contains leading/trailing whitespace
options = { ...options, replyTo: options.replyTo.trim() }
}
// Batch mode
if (options.batch) {
return pipe(
readStdin,
Effect.flatMap(parseJson),
Effect.flatMap(
Schema.decodeUnknown(BatchCommentSchema, {
errors: 'all',
onExcessProperty: 'ignore',
}),
),
Effect.mapError((error) => {
// Extract the actual schema validation errors
let errorMessage = 'Invalid batch input format.\n'
if (ParseResult.isParseError(error)) {
// Format the parse error with details
errorMessage += TreeFormatter.formatErrorSync(error)
errorMessage += '\n\nExpected format: [{"file": "...", "line": ..., "message": "..."}]'
} else if (error instanceof Error) {
errorMessage += error.message
} else {
errorMessage +=
'Expected: [{"file": "...", "line": ..., "message": "...", "side"?: "PARENT|REVISION", "range"?: {...}}]'
}
return new Error(errorMessage)
}),
Effect.map(buildBatchReview),
)
}
// Line comment mode
if (options.file && options.line) {
return options.message
? Effect.succeed({
comments: {
[options.file]: [
{
line: options.line,
message: options.message,
...(options.replyTo !== undefined ? { in_reply_to: options.replyTo } : {}),
// When replying, default unresolved to false (resolves the thread) unless explicitly set
...(options.replyTo !== undefined
? { unresolved: options.unresolved ?? false }
: options.unresolved !== undefined
? { unresolved: options.unresolved }
: {}),
},
],
},
})
: Effect.fail(new Error('Message is required for line comments. Use -m "your message"'))
}
// Overall comment mode
if (options.message) {
return Effect.succeed({ message: options.message })
}
// If no message provided, read from stdin (for piping support)
return pipe(
readStdin,
Effect.map((stdinContent) => stdinContent.trim()),
Effect.flatMap((message) =>
message.length > 0
? Effect.succeed({ message })
: Effect.fail(
new Error('Message is required. Use -m "your message" or pipe content to stdin'),
),
),
)
}
// Export a version that accepts direct input instead of reading stdin
export const commentCommandWithInput = (
changeId: string,
input: string,
options: CommentOptions,
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
Effect.gen(function* () {
const apiService = yield* GerritApiService
// Build the review input from provided string
const review = yield* createReviewInputFromString(input, options)
// Execute the API calls in sequence
const change = yield* pipe(
apiService.getChange(changeId),
Effect.mapError((error) =>
error._tag === 'ApiError' ? new Error(`Failed to get change: ${error.message}`) : error,
),
)
yield* pipe(
apiService.postReview(changeId, review),
Effect.mapError((error) => {
if (error._tag === 'ApiError') {
// Build detailed error context for batch comments
if (options.batch && review.comments) {
const commentDetails = Object.entries(review.comments)
.flatMap(([file, comments]) =>
comments.map((comment) => {
const parts = [`${file}:${comment.line || 'range'}`]
if (comment.message?.length > 50) {
parts.push(`"${comment.message.slice(0, 50)}..."`)
} else {
parts.push(`"${comment.message}"`)
}
return parts.join(' ')
}),
)
.join(', ')
return new Error(
`Failed to post comment: ${error.message}\nTried to post: ${commentDetails}`,
)
}
// Single line comment context
if (options.file && options.line) {
return new Error(
`Failed to post comment: ${error.message}\nTried to post to ${options.file}:${options.line}: "${options.message}"`,
)
}
// Overall comment context
if (options.message) {
const msg =
options.message.length > 50 ? `${options.message.slice(0, 50)}...` : options.message
return new Error(
`Failed to post comment: ${error.message}\nTried to post overall comment: "${msg}"`,
)
}
return new Error(`Failed to post comment: ${error.message}`)
}
return error
}),
)
// Format and display output
yield* formatOutput(change, review, options, changeId)
})
export const commentCommand = (
changeId: string,
options: CommentOptions,
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
Effect.gen(function* () {
const apiService = yield* GerritApiService
// Build the review input
const review = yield* createReviewInput(options)
// Execute the API calls in sequence
const change = yield* pipe(
apiService.getChange(changeId),
Effect.mapError((error) =>
error._tag === 'ApiError' ? new Error(`Failed to get change: ${error.message}`) : error,
),
)
yield* pipe(
apiService.postReview(changeId, review),
Effect.mapError((error) => {
if (error._tag === 'ApiError') {
// Build detailed error context for batch comments
if (options.batch && review.comments) {
const commentDetails = Object.entries(review.comments)
.flatMap(([file, comments]) =>
comments.map((comment) => {
const parts = [`${file}:${comment.line || 'range'}`]
if (comment.message?.length > 50) {
parts.push(`"${comment.message.slice(0, 50)}..."`)
} else {
parts.push(`"${comment.message}"`)
}
return parts.join(' ')
}),
)
.join(', ')
return new Error(
`Failed to post comment: ${error.message}\nTried to post: ${commentDetails}`,
)
}
// Single line comment context
if (options.file && options.line) {
return new Error(
`Failed to post comment: ${error.message}\nTried to post to ${options.file}:${options.line}: "${options.message}"`,
)
}
// Overall comment context
if (options.message) {
const msg =
options.message.length > 50 ? `${options.message.slice(0, 50)}...` : options.message
return new Error(
`Failed to post comment: ${error.message}\nTried to post overall comment: "${msg}"`,
)
}
return new Error(`Failed to post comment: ${error.message}`)
}
return error
}),
)
// Format and display output
yield* formatOutput(change, review, options, changeId)
})
// Helper to format XML output
const formatXmlOutput = (
change: ChangeInfo,
review: ReviewInput,
options: CommentOptions,
changeId: string,
): Effect.Effect<void> =>
Effect.sync(() => {
const lines: string[] = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<comment_result>`,
` <status>success</status>`,
` <change_id>${changeId}</change_id>`,
` <change_number>${change._number}</change_number>`,
` <change_subject><![CDATA[${change.subject}]]></change_subject>`,
` <change_status>${change.status}</change_status>`,
]
if (options.batch && review.comments) {
lines.push(` <comments>`)
for (const [file, comments] of Object.entries(review.comments)) {
for (const comment of comments) {
lines.push(` <comment>`)
lines.push(` <file>${file}</file>`)
if (comment.line) lines.push(` <line>${comment.line}</line>`)
lines.push(` <message><![CDATA[${comment.message}]]></message>`)
if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
lines.push(` </comment>`)
}
}
lines.push(` </comments>`)
} else if (options.file && options.line) {
lines.push(` <comment>`)
lines.push(` <file>${options.file}</file>`)
lines.push(` <line>${options.line}</line>`)
if (options.replyTo) lines.push(` <in_reply_to>${options.replyTo}</in_reply_to>`)
lines.push(` <message><![CDATA[${options.message}]]></message>`)
// Always emit unresolved when replying so callers know thread resolution state
if (options.replyTo !== undefined) {
lines.push(` <unresolved>${(options.unresolved ?? false).toString()}</unresolved>`)
} else if (options.unresolved) {
lines.push(` <unresolved>true</unresolved>`)
}
lines.push(` </comment>`)
} else {
lines.push(` <message><![CDATA[${options.message}]]></message>`)
}
lines.push(`</comment_result>`)
for (const line of lines) {
console.log(line)
}
})
// Helper to format human-readable output
const formatHumanOutput = (
change: ChangeInfo,
review: ReviewInput,
options: CommentOptions,
): Effect.Effect<void> =>
Effect.sync(() => {
console.log(`✓ Comment posted successfully!`)
console.log(`Change: ${change.subject} (${change.status})`)
if (options.batch && review.comments) {
const totalComments = Object.values(review.comments).reduce(
(sum, comments) => sum + comments.length,
0,
)
console.log(`Posted ${totalComments} line comment(s)`)
} else if (options.file && options.line) {
console.log(`File: ${options.file}, Line: ${options.line}`)
if (options.replyTo) {
const resolved = !(options.unresolved ?? false)
console.log(`Reply to: ${options.replyTo} (thread ${resolved ? 'resolved' : 'unresolved'})`)
}
console.log(`Message: ${options.message}`)
if (options.unresolved) console.log(`Status: Unresolved`)
}
// Note: For overall review messages, we don't display the content here
// since it was already shown in the "OVERALL REVIEW TO POST" section
})
// Helper to format JSON output
const formatJsonOutput = (
change: ChangeInfo,
review: ReviewInput,
options: CommentOptions,
changeId: string,
): Effect.Effect<void> =>
Effect.sync(() => {
const output: Record<string, unknown> = {
status: 'success',
change_id: changeId,
change_number: change._number,
change_subject: change.subject,
change_status: change.status,
}
if (options.batch && review.comments) {
output.comments = Object.entries(review.comments).flatMap(([file, comments]) =>
comments.map((comment) => ({
file,
...(comment.line ? { line: comment.line } : {}),
message: comment.message,
...(comment.unresolved ? { unresolved: true } : {}),
})),
)
} else if (options.file && options.line) {
output.comment = {
file: options.file,
line: options.line,
...(options.replyTo ? { in_reply_to: options.replyTo } : {}),
message: options.message,
// Always include unresolved when replying so callers know thread resolution state
...(options.replyTo !== undefined
? { unresolved: options.unresolved ?? false }
: options.unresolved
? { unresolved: true }
: {}),
}
} else {
output.message = options.message
}
console.log(JSON.stringify(output, null, 2))
})
// Main output formatter
const formatOutput = (
change: ChangeInfo,
review: ReviewInput,
options: CommentOptions,
changeId: string,
): Effect.Effect<void> =>
options.json
? formatJsonOutput(change, review, options, changeId)
: options.xml
? formatXmlOutput(change, review, options, changeId)
: formatHumanOutput(change, review, options)