UNPKG

@aaronshaf/ger

Version:

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

614 lines (564 loc) 20.5 kB
import { Effect } from 'effect' import { type ApiError, GerritApiService } from '@/api/gerrit' import type { CommentInfo, MessageInfo } from '@/schemas/gerrit' import { formatCommentsPretty } from '@/utils/comment-formatters' import { getDiffContext } from '@/utils/diff-context' import { formatDiffPretty } from '@/utils/diff-formatters' import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety' import { formatDate } from '@/utils/formatters' import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit' export const SHOW_HELP_TEXT = ` Examples: # Show specific change (using change number) $ ger show 392385 # Show specific change (using Change-ID) $ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1 # Auto-detect Change-ID from HEAD commit $ ger show $ ger show --xml $ ger show --json # Extract build failure URL with jq $ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+' Note: When no change-id is provided, it will be automatically extracted from the Change-ID footer in your HEAD commit. You must be in a git repository with a commit that has a Change-ID.` interface ShowOptions { xml?: boolean json?: boolean } interface ReviewerIdentity { accountId?: number name?: string email?: string username?: string } interface ChangeDetails { id: string number: number subject: string status: string project: string branch: string owner: { name?: string email?: string } created?: string updated?: string commitMessage: string topic?: string reviewers: ReviewerIdentity[] ccs: ReviewerIdentity[] } const formatReviewerLabel = (reviewer: ReviewerIdentity): string => { const preferredIdentity = reviewer.name || reviewer.email || reviewer.username if (!preferredIdentity) { if (reviewer.accountId !== undefined) { return `Account ${reviewer.accountId}` } return 'Unknown Reviewer' } if (reviewer.email && reviewer.name && reviewer.name !== reviewer.email) { return `${reviewer.name} <${reviewer.email}>` } return preferredIdentity } const getChangeDetails = ( changeId: string, ): Effect.Effect<ChangeDetails, ApiError, GerritApiService> => Effect.gen(function* () { const gerritApi = yield* GerritApiService const change = yield* gerritApi.getChange(changeId) let reviewerMap = change.reviewers const shouldFetchReviewerFallback = reviewerMap === undefined || (reviewerMap.REVIEWER === undefined && reviewerMap.CC === undefined) if (shouldFetchReviewerFallback) { const detailedChanges = yield* gerritApi .listChanges(`change:${change._number}`) .pipe(Effect.catchAll(() => Effect.succeed([]))) const detailedChange = detailedChanges.find((candidate) => candidate._number === change._number) || detailedChanges[0] reviewerMap = detailedChange?.reviewers } return { id: change.change_id, number: change._number, subject: change.subject, status: change.status, project: change.project, branch: change.branch, owner: { name: change.owner?.name, email: change.owner?.email, }, created: change.created, updated: change.updated, commitMessage: change.subject, // For now, using subject as commit message topic: change.topic, reviewers: (reviewerMap?.REVIEWER ?? []).map((reviewer) => ({ accountId: reviewer._account_id, name: reviewer.name, email: reviewer.email, username: reviewer.username, })), ccs: (reviewerMap?.CC ?? []).map((cc) => ({ accountId: cc._account_id, name: cc.name, email: cc.email, username: cc.username, })), } }) const getDiffForChange = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> => Effect.gen(function* () { const gerritApi = yield* GerritApiService const diff = yield* gerritApi.getDiff(changeId, { format: 'unified' }) return typeof diff === 'string' ? diff : JSON.stringify(diff, null, 2) }) const getCommentsAndMessagesForChange = ( changeId: string, ): Effect.Effect< { comments: CommentInfo[]; messages: MessageInfo[] }, ApiError, GerritApiService > => Effect.gen(function* () { const gerritApi = yield* GerritApiService // Get both inline comments and review messages concurrently const [comments, messages] = yield* Effect.all( [gerritApi.getComments(changeId), gerritApi.getMessages(changeId)], { concurrency: 'unbounded' }, ) // Flatten all inline comments from all files const allComments: CommentInfo[] = [] for (const [path, fileComments] of Object.entries(comments)) { for (const comment of fileComments) { allComments.push({ ...comment, path: path === '/COMMIT_MSG' ? 'Commit Message' : path, }) } } // Sort inline comments by date (ascending - oldest first) allComments.sort((a, b) => { const dateA = a.updated ? new Date(a.updated).getTime() : 0 const dateB = b.updated ? new Date(b.updated).getTime() : 0 return dateA - dateB }) // Sort messages by date (ascending - oldest first) const sortedMessages = [...messages].sort((a, b) => { const dateA = new Date(a.date).getTime() const dateB = new Date(b.date).getTime() return dateA - dateB }) return { comments: allComments, messages: sortedMessages } }) const formatShowPretty = ( changeDetails: ChangeDetails, diff: string, commentsWithContext: Array<{ comment: CommentInfo; context?: any }>, messages: MessageInfo[], ): void => { // Change details header console.log('━'.repeat(80)) console.log(`📋 Change ${changeDetails.number}: ${changeDetails.subject}`) console.log('━'.repeat(80)) console.log() // Metadata console.log('📝 Details:') console.log(` Project: ${changeDetails.project}`) console.log(` Branch: ${changeDetails.branch}`) console.log(` Status: ${changeDetails.status}`) if (changeDetails.topic) { console.log(` Topic: ${changeDetails.topic}`) } console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`) console.log( ` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`, ) console.log( ` Updated: ${changeDetails.updated ? formatDate(changeDetails.updated) : 'Unknown'}`, ) if (changeDetails.reviewers.length > 0) { console.log( ` Reviewers: ${changeDetails.reviewers.map((reviewer) => formatReviewerLabel(reviewer)).join(', ')}`, ) } if (changeDetails.ccs.length > 0) { console.log(` CCs: ${changeDetails.ccs.map((cc) => formatReviewerLabel(cc)).join(', ')}`) } console.log(` Change-Id: ${changeDetails.id}`) console.log() // Diff section console.log('🔍 Diff:') console.log('─'.repeat(40)) console.log(formatDiffPretty(diff)) console.log() // Comments and Messages section const hasComments = commentsWithContext.length > 0 const hasMessages = messages.length > 0 if (hasComments) { console.log('💬 Inline Comments:') console.log('─'.repeat(40)) formatCommentsPretty(commentsWithContext) console.log() } if (hasMessages) { console.log('📝 Review Activity:') console.log('─'.repeat(40)) for (const message of messages) { const author = message.author?.name || 'Unknown' const date = formatDate(message.date) const cleanMessage = message.message.trim() // Skip very short automated messages if ( cleanMessage.length < 10 && (cleanMessage.includes('Build') || cleanMessage.includes('Patch')) ) { continue } console.log(`📅 ${date} - ${author}`) console.log(` ${cleanMessage}`) console.log() } } if (!hasComments && !hasMessages) { console.log('💬 Comments & Activity:') console.log('─'.repeat(40)) console.log('No comments or review activity found.') } } // Helper to remove undefined values from objects const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => { return Object.fromEntries( Object.entries(obj).filter(([_, value]) => value !== undefined), ) as Partial<T> } const formatShowJson = async ( changeDetails: ChangeDetails, diff: string, commentsWithContext: Array<{ comment: CommentInfo; context?: any }>, messages: MessageInfo[], ): Promise<void> => { const output = { status: 'success', change: removeUndefined({ id: changeDetails.id, number: changeDetails.number, subject: changeDetails.subject, status: changeDetails.status, project: changeDetails.project, branch: changeDetails.branch, topic: changeDetails.topic, owner: removeUndefined(changeDetails.owner), reviewers: changeDetails.reviewers.map((reviewer) => removeUndefined({ account_id: reviewer.accountId, name: reviewer.name, email: reviewer.email, username: reviewer.username, }), ), ccs: changeDetails.ccs.map((cc) => removeUndefined({ account_id: cc.accountId, name: cc.name, email: cc.email, username: cc.username, }), ), created: changeDetails.created, updated: changeDetails.updated, }), diff, comments: commentsWithContext.map(({ comment, context }) => removeUndefined({ id: comment.id, path: comment.path, line: comment.line, range: comment.range, author: comment.author ? removeUndefined({ name: comment.author.name, email: comment.author.email, account_id: comment.author._account_id, }) : undefined, updated: comment.updated, message: comment.message, unresolved: comment.unresolved, in_reply_to: comment.in_reply_to, context, }), ), messages: messages.map((message) => removeUndefined({ id: message.id, author: message.author ? removeUndefined({ name: message.author.name, email: message.author.email, account_id: message.author._account_id, }) : undefined, date: message.date, message: message.message, revision: message._revision_number, tag: message.tag, }), ), } const jsonOutput = JSON.stringify(output, null, 2) + '\n' // Write to stdout and ensure all data is flushed before process exits // Using process.stdout.write with drain handling for large payloads return new Promise<void>((resolve, reject) => { const written = process.stdout.write(jsonOutput, (err) => { if (err) { reject(err) } else { resolve() } }) if (!written) { // If write returned false, buffer is full, wait for drain process.stdout.once('drain', resolve) process.stdout.once('error', reject) } }) } const formatShowXml = async ( changeDetails: ChangeDetails, diff: string, commentsWithContext: Array<{ comment: CommentInfo; context?: any }>, messages: MessageInfo[], ): Promise<void> => { // Build complete XML output as a single string to avoid multiple writes const xmlParts: string[] = [] xmlParts.push(`<?xml version="1.0" encoding="UTF-8"?>`) xmlParts.push(`<show_result>`) xmlParts.push(` <status>success</status>`) xmlParts.push(` <change>`) xmlParts.push(` <id>${escapeXML(changeDetails.id)}</id>`) xmlParts.push(` <number>${changeDetails.number}</number>`) xmlParts.push(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`) xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`) xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`) xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`) if (changeDetails.topic) { xmlParts.push(` <topic><![CDATA[${sanitizeCDATA(changeDetails.topic)}]]></topic>`) } xmlParts.push(` <owner>`) if (changeDetails.owner.name) { xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`) } if (changeDetails.owner.email) { xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`) } xmlParts.push(` </owner>`) xmlParts.push(` <reviewers>`) xmlParts.push(` <count>${changeDetails.reviewers.length}</count>`) for (const reviewer of changeDetails.reviewers) { xmlParts.push(` <reviewer>`) if (reviewer.accountId !== undefined) { xmlParts.push(` <account_id>${reviewer.accountId}</account_id>`) } if (reviewer.name) { xmlParts.push(` <name><![CDATA[${sanitizeCDATA(reviewer.name)}]]></name>`) } if (reviewer.email) { xmlParts.push(` <email>${escapeXML(reviewer.email)}</email>`) } if (reviewer.username) { xmlParts.push(` <username>${escapeXML(reviewer.username)}</username>`) } xmlParts.push(` </reviewer>`) } xmlParts.push(` </reviewers>`) xmlParts.push(` <ccs>`) xmlParts.push(` <count>${changeDetails.ccs.length}</count>`) for (const cc of changeDetails.ccs) { xmlParts.push(` <cc>`) if (cc.accountId !== undefined) { xmlParts.push(` <account_id>${cc.accountId}</account_id>`) } if (cc.name) { xmlParts.push(` <name><![CDATA[${sanitizeCDATA(cc.name)}]]></name>`) } if (cc.email) { xmlParts.push(` <email>${escapeXML(cc.email)}</email>`) } if (cc.username) { xmlParts.push(` <username>${escapeXML(cc.username)}</username>`) } xmlParts.push(` </cc>`) } xmlParts.push(` </ccs>`) xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`) xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`) xmlParts.push(` </change>`) xmlParts.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`) // Comments section xmlParts.push(` <comments>`) xmlParts.push(` <count>${commentsWithContext.length}</count>`) for (const { comment } of commentsWithContext) { xmlParts.push(` <comment>`) if (comment.id) xmlParts.push(` <id>${escapeXML(comment.id)}</id>`) if (comment.path) xmlParts.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`) if (comment.line) xmlParts.push(` <line>${comment.line}</line>`) if (comment.author?.name) { xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`) } if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`) if (comment.message) { xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`) } if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`) xmlParts.push(` </comment>`) } xmlParts.push(` </comments>`) // Messages section xmlParts.push(` <messages>`) xmlParts.push(` <count>${messages.length}</count>`) for (const message of messages) { xmlParts.push(` <message>`) xmlParts.push(` <id>${escapeXML(message.id)}</id>`) if (message.author?.name) { xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`) } if (message.author?._account_id) { xmlParts.push(` <author_id>${message.author._account_id}</author_id>`) } xmlParts.push(` <date>${escapeXML(message.date)}</date>`) if (message._revision_number) { xmlParts.push(` <revision>${message._revision_number}</revision>`) } if (message.tag) { xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`) } xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`) xmlParts.push(` </message>`) } xmlParts.push(` </messages>`) xmlParts.push(`</show_result>`) const xmlOutput = xmlParts.join('\n') + '\n' // Write to stdout with proper drain handling for large payloads return new Promise<void>((resolve, reject) => { const written = process.stdout.write(xmlOutput, (err) => { if (err) { reject(err) } else { resolve() } }) if (!written) { process.stdout.once('drain', resolve) process.stdout.once('error', reject) } }) } export const showCommand = ( changeId: string | undefined, options: ShowOptions, ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> => Effect.gen(function* () { // Auto-detect Change-ID from HEAD commit if not provided const resolvedChangeId = changeId || (yield* getChangeIdFromHead()) // Fetch all data concurrently const [changeDetails, diff, commentsAndMessages] = yield* Effect.all( [ getChangeDetails(resolvedChangeId), getDiffForChange(resolvedChangeId), getCommentsAndMessagesForChange(resolvedChangeId), ], { concurrency: 'unbounded' }, ) const { comments, messages } = commentsAndMessages // Get context for each comment using concurrent requests const contextEffects = comments.map((comment) => comment.path && comment.line ? getDiffContext(resolvedChangeId, comment.path, comment.line).pipe( Effect.map((context) => ({ comment, context })), // Graceful degradation for diff fetch failures Effect.catchAll(() => Effect.succeed({ comment, context: undefined })), ) : Effect.succeed({ comment, context: undefined }), ) // Execute all context fetches concurrently const commentsWithContext = yield* Effect.all(contextEffects, { concurrency: 'unbounded', }) // Format output if (options.json) { yield* Effect.promise(() => formatShowJson(changeDetails, diff, commentsWithContext, messages), ) } else if (options.xml) { yield* Effect.promise(() => formatShowXml(changeDetails, diff, commentsWithContext, messages)) } else { formatShowPretty(changeDetails, diff, commentsWithContext, messages) } }).pipe( // Regional error boundary for the entire command Effect.catchAll((error) => { const errorMessage = error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error ? error.message : String(error) if (options.json) { return Effect.promise( () => new Promise<void>((resolve, reject) => { const errorOutput = JSON.stringify( { status: 'error', error: errorMessage, }, null, 2, ) + '\n' const written = process.stdout.write(errorOutput, (err) => { if (err) { reject(err) } else { resolve() } }) if (!written) { // Wait for drain if buffer is full process.stdout.once('drain', resolve) process.stdout.once('error', reject) } }), ) } else if (options.xml) { return Effect.promise( () => new Promise<void>((resolve, reject) => { const xmlError = `<?xml version="1.0" encoding="UTF-8"?>\n` + `<show_result>\n` + ` <status>error</status>\n` + ` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>\n` + `</show_result>\n` const written = process.stdout.write(xmlError, (err) => { if (err) { reject(err) } else { resolve() } }) if (!written) { process.stdout.once('drain', resolve) process.stdout.once('error', reject) } }), ) } else { console.error(`✗ Error: ${errorMessage}`) } return Effect.succeed(undefined) }), )