UNPKG

@aaronshaf/ger

Version:

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

222 lines (195 loc) 8.75 kB
import { Effect } from 'effect' import { type ApiError, GerritApiService } from '@/api/gerrit' import type { ChangeInfo } from '@/schemas/gerrit' import { colors, formatDate } from '@/utils/formatters' import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety' import { getStatusIndicators } from '@/utils/status-indicators' export const SEARCH_HELP_TEXT = ` Examples: # Search for all open changes (default) $ ger search # Search for your open changes $ ger search "owner:self status:open" # Search for changes by a specific user $ ger search "owner:john@example.com" # Search by project $ ger search "project:my-project status:open" # Search with date filters $ ger search "owner:self after:2025-01-01" $ ger search "status:merged age:7d" # Combine filters $ ger search "owner:self status:merged before:2025-06-01" # Limit results $ ger search "project:my-project" -n 10 Common query operators: owner:USER Changes owned by USER (use 'self' for yourself) status:STATE open, merged, abandoned, closed project:NAME Changes in a specific project branch:NAME Changes targeting a branch age:TIME Time since last update (e.g., 1d, 2w, 1mon) before:DATE Changes modified before date (YYYY-MM-DD) after:DATE Changes modified after date (YYYY-MM-DD) is:wip Work-in-progress changes is:submittable Changes ready to submit reviewer:USER Changes where USER is a reviewer label:NAME=VALUE Filter by label (e.g., label:Code-Review+2) Full query syntax: https://gerrit-review.googlesource.com/Documentation/user-search.html` interface SearchOptions { xml?: boolean json?: boolean limit?: string } // Group changes by project for better organization const groupChangesByProject = (changes: readonly ChangeInfo[]) => { const grouped = new Map<string, ChangeInfo[]>() for (const change of changes) { const project = change.project const existing = grouped.get(project) ?? [] existing.push(change) grouped.set(project, existing) } // Sort projects alphabetically and changes by updated date return Array.from(grouped.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([project, projectChanges]) => ({ project, changes: projectChanges.sort((a, b) => { const dateA = a.updated ? new Date(a.updated).getTime() : 0 const dateB = b.updated ? new Date(b.updated).getTime() : 0 return dateB - dateA }), })) } export const searchCommand = ( query: string | undefined, options: SearchOptions, ): Effect.Effect<void, ApiError, GerritApiService> => Effect.gen(function* () { const gerritApi = yield* GerritApiService // Build the final query with limit if specified let finalQuery = query || 'is:open' const parsedLimit = options.limit ? parseInt(options.limit, 10) : 25 const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 25 : parsedLimit if (!finalQuery.includes('limit:')) { finalQuery = `${finalQuery} limit:${limit}` } const changes = yield* gerritApi.listChanges(finalQuery) // Group changes by project (used by both output formats) const groupedChanges = changes.length > 0 ? groupChangesByProject(changes) : [] if (options.json) { // JSON output const jsonOutput = { status: 'success', query: finalQuery, count: changes.length, changes: groupedChanges.flatMap(({ project, changes: projectChanges }) => projectChanges.map((change) => ({ number: change._number, id: change.id, change_id: change.change_id, subject: change.subject, status: change.status, project, branch: change.branch, owner: change.owner?.name ?? 'Unknown', ...(change.owner?._account_id !== undefined ? { owner_account_id: change.owner._account_id } : {}), ...(change.owner?.email ? { owner_email: change.owner.email } : {}), ...(change.owner?.username ? { owner_username: change.owner.username } : {}), ...(change.created ? { created: change.created } : {}), ...(change.updated ? { updated: change.updated } : {}), ...(change.insertions !== undefined ? { insertions: change.insertions } : {}), ...(change.deletions !== undefined ? { deletions: change.deletions } : {}), ...(change.current_revision ? { current_revision: change.current_revision } : {}), ...(change.submittable !== undefined ? { submittable: change.submittable } : {}), ...(change.work_in_progress !== undefined ? { work_in_progress: change.work_in_progress } : {}), ...(change.topic ? { topic: change.topic } : {}), ...(change.labels && Object.keys(change.labels).length > 0 ? { labels: change.labels } : {}), ...(change.reviewers?.REVIEWER && change.reviewers.REVIEWER.length > 0 ? { reviewers: change.reviewers.REVIEWER.map((r) => ({ ...(r._account_id !== undefined ? { account_id: r._account_id } : {}), ...(r.name ? { name: r.name } : {}), ...(r.email ? { email: r.email } : {}), ...(r.username ? { username: r.username } : {}), })), } : {}), ...(change.reviewers?.CC && change.reviewers.CC.length > 0 ? { cc: change.reviewers.CC.map((r) => ({ ...(r._account_id !== undefined ? { account_id: r._account_id } : {}), ...(r.name ? { name: r.name } : {}), ...(r.email ? { email: r.email } : {}), ...(r.username ? { username: r.username } : {}), })), } : {}), })), ), } console.log(JSON.stringify(jsonOutput, null, 2)) } else if (options.xml) { // XML output const xmlOutput = [ '<?xml version="1.0" encoding="UTF-8"?>', '<search_results>', ` <query><![CDATA[${sanitizeCDATA(finalQuery)}]]></query>`, ` <count>${changes.length}</count>`, ] if (changes.length > 0) { xmlOutput.push(' <changes>') for (const { project, changes: projectChanges } of groupedChanges) { xmlOutput.push(` <project name="${escapeXML(project)}">`) for (const change of projectChanges) { xmlOutput.push(' <change>') xmlOutput.push(` <number>${change._number}</number>`) xmlOutput.push( ` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`, ) xmlOutput.push(` <status>${escapeXML(change.status)}</status>`) xmlOutput.push(` <owner>${escapeXML(change.owner?.name ?? 'Unknown')}</owner>`) xmlOutput.push(` <branch>${escapeXML(change.branch)}</branch>`) if (change.updated && change.updated.trim() !== '') { xmlOutput.push(` <updated>${escapeXML(change.updated)}</updated>`) } if (change.owner?.email) { xmlOutput.push(` <owner_email>${escapeXML(change.owner.email)}</owner_email>`) } xmlOutput.push(' </change>') } xmlOutput.push(' </project>') } xmlOutput.push(' </changes>') } xmlOutput.push('</search_results>') console.log(xmlOutput.join('\n')) } else { // Pretty output (default) if (changes.length === 0) { console.log(`${colors.yellow}No changes found${colors.reset}`) return } console.log(`${colors.blue}Search results (${changes.length})${colors.reset}\n`) for (const { project, changes: projectChanges } of groupedChanges) { console.log(`${colors.gray}${project}${colors.reset}`) for (const change of projectChanges) { const indicators = getStatusIndicators(change) const statusPart = indicators.length > 0 ? `${indicators.join(' ')} ` : '' const dateStr = change.updated ? ` • ${formatDate(change.updated)}` : '' console.log( ` ${statusPart}${colors.yellow}#${change._number}${colors.reset} ${change.subject}`, ) console.log( ` ${colors.gray}by ${change.owner?.name ?? 'Unknown'}${change.status}${dateStr}${colors.reset}`, ) } console.log() // Empty line between projects } } })