UNPKG

@aaronshaf/ger

Version:

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

240 lines (211 loc) 9.03 kB
import { Effect } from 'effect' import chalk from 'chalk' import { type ApiError, GerritApiService } from '@/api/gerrit' import type { ChangeInfo } from '@/schemas/gerrit' import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config' export interface ListOptions { status?: string limit?: number detailed?: boolean reviewer?: boolean allVerified?: boolean filter?: string json?: boolean xml?: boolean } type LabelInfo = NonNullable<ChangeInfo['labels']>[string] // ── Label score helpers ──────────────────────────────────────────────────── const getLabelScore = (label: LabelInfo): number | null => { if (label.approved) return 2 if (label.rejected) return -2 if (label.recommended) return 1 if (label.disliked) return -1 if (label.value !== undefined && label.value !== 0) return label.value return null } const fmtCR = (label: LabelInfo | undefined): string => { if (!label) return chalk.gray('—') const s = getLabelScore(label) if (s === null || s === 0) return chalk.gray('0') if (s >= 2) return chalk.bold.green('+2') if (s === 1) return chalk.cyan('+1') if (s === -1) return chalk.yellow('-1') return chalk.bold.red('-2') } const fmtVerified = (label: LabelInfo | undefined): string => { if (!label) return chalk.gray('—') const s = getLabelScore(label) if (s === null || s === 0) return chalk.gray('—') if (s > 0) return chalk.green('V+') return chalk.red('V-') } const fmtLabel = (label: LabelInfo | undefined): string => { if (!label) return chalk.gray('—') const s = getLabelScore(label) if (s === null || s === 0) return chalk.gray('—') if (s > 0) return chalk.green(`+${s}`) return chalk.red(String(s)) } // ── Time-ago ─────────────────────────────────────────────────────────────── const timeAgo = (dateStr: string): string => { const ms = Date.now() - new Date(dateStr.replace(' ', 'T').split('.')[0] + 'Z').getTime() const mins = Math.floor(ms / 60000) if (mins < 60) return `${mins}m ago` const hrs = Math.floor(mins / 60) if (hrs < 24) return `${hrs}h ago` const days = Math.floor(hrs / 24) if (days < 14) return `${days}d ago` const weeks = Math.floor(days / 7) if (weeks < 8) return `${weeks}w ago` return dateStr.slice(0, 10) } // ── Table rendering ──────────────────────────────────────────────────────── const COL_CHANGE = 8 const COL_SUBJECT_MINE = 58 const COL_SUBJECT_TEAM = 45 const COL_OWNER = 20 const COL_SCORE = 4 const COL_UPDATED = 10 const pad = (s: string, width: number): string => { const visible = s.replace(/\x1b\[[0-9;]*m/g, '') const extra = s.length - visible.length return s.padEnd(width + extra) } const truncate = (s: string, max: number): string => s.length > max ? `${s.slice(0, max - 1)}…` : s const getOwnerLabel = (change: ChangeInfo): string => change.owner?.name ?? change.owner?.email ?? String(change.owner?._account_id ?? '—') const renderTableHeader = (showOwner: boolean): void => { const h = chalk.bold const colSubject = showOwner ? COL_SUBJECT_TEAM : COL_SUBJECT_MINE const ownerCol = showOwner ? ` ${h(pad('Owner', COL_OWNER))}` : '' console.log( ` ${h(pad('Change', COL_CHANGE))} ${h(pad('Subject', colSubject))}${ownerCol} ` + `${h(pad('CR', COL_SCORE))} ${h(pad('QR', COL_SCORE))} ` + `${h(pad('LR', COL_SCORE))} ${h(pad('Verified', 8))} ${h('Updated')}`, ) const d = '─' const ownerDiv = showOwner ? ` ${d.repeat(COL_OWNER)}` : '' console.log( ` ${d.repeat(COL_CHANGE)} ${d.repeat(colSubject)}${ownerDiv} ` + `${d.repeat(COL_SCORE)} ${d.repeat(COL_SCORE)} ` + `${d.repeat(COL_SCORE)} ${d.repeat(8)} ${d.repeat(COL_UPDATED)}`, ) } const renderTableRow = (change: ChangeInfo, showOwner: boolean): void => { const colSubject = showOwner ? COL_SUBJECT_TEAM : COL_SUBJECT_MINE const num = chalk.cyan(pad(String(change._number), COL_CHANGE)) const subject = pad(truncate(change.subject, colSubject), colSubject) const ownerCol = showOwner ? ` ${pad(truncate(getOwnerLabel(change), COL_OWNER), COL_OWNER)}` : '' const cr = pad(fmtCR(change.labels?.['Code-Review']), COL_SCORE) const qr = pad(fmtLabel(change.labels?.['QA-Review']), COL_SCORE) const lr = pad(fmtLabel(change.labels?.['Lint-Review']), COL_SCORE) const verified = pad(fmtVerified(change.labels?.['Verified']), 8) const updated = timeAgo(change.updated ?? change.created ?? '') console.log(` ${num} ${subject}${ownerCol} ${cr} ${qr} ${lr} ${verified} ${updated}`) } const renderDetailed = (change: ChangeInfo): void => { console.log(`${chalk.bold.cyan('Change:')} ${chalk.bold(String(change._number))}`) console.log(`${chalk.bold.cyan('Subject:')} ${change.subject}`) console.log(`${chalk.bold.cyan('Status:')} ${change.status}`) console.log(`${chalk.bold.cyan('Project:')} ${change.project}`) console.log(`${chalk.bold.cyan('Branch:')} ${change.branch}`) if (change.owner?.name) console.log(`${chalk.bold.cyan('Owner:')} ${change.owner.name}`) if (change.updated) console.log(`${chalk.bold.cyan('Updated:')} ${timeAgo(change.updated)}`) const labels = change.labels if (labels && Object.keys(labels).length > 0) { const scores = Object.entries(labels) .map(([name, info]) => { const s = getLabelScore(info) if (s === null) return null const formatted = s > 0 ? chalk.green(`+${s}`) : chalk.red(String(s)) return `${name}:${formatted}` }) .filter((x): x is string => x !== null) if (scores.length > 0) { console.log(`${chalk.bold.cyan('Reviews:')} ${scores.join(' ')}`) } } } // ── Command ──────────────────────────────────────────────────────────────── export const listCommand = ( options: ListOptions, ): Effect.Effect<void, ApiError | ConfigError, ConfigServiceImpl | GerritApiService> => Effect.gen(function* () { const configService = yield* ConfigService const credentials = yield* configService.getCredentials const gerritApi = yield* GerritApiService const status = options.status ?? 'open' const limit = options.limit ?? 25 const user = credentials.username const baseQuery = options.reviewer ? `(reviewer:${user} OR cc:${user}) status:${status}` : `owner:${user} status:${status}` const query = options.filter ? `${baseQuery} ${options.filter}` : baseQuery const changes = yield* gerritApi.listChanges(query) const limited = changes.slice(0, limit) if (options.json) { console.log( JSON.stringify( { status: 'success', count: limited.length, changes: limited.map((c) => ({ number: c._number, subject: c.subject, project: c.project, branch: c.branch, status: c.status, change_id: c.change_id, ...(c.updated ? { updated: c.updated } : {}), ...(c.owner?.name ? { owner: c.owner.name } : {}), ...(c.labels ? { labels: c.labels } : {}), })), }, null, 2, ), ) return } if (options.xml) { console.log(`<?xml version="1.0" encoding="UTF-8"?>`) console.log(`<changes count="${limited.length}">`) for (const c of limited) { console.log(` <change>`) console.log(` <number>${c._number}</number>`) console.log(` <subject><![CDATA[${c.subject}]]></subject>`) console.log(` <project>${c.project}</project>`) console.log(` <branch>${c.branch}</branch>`) console.log(` <status>${c.status}</status>`) console.log(` <change_id>${c.change_id}</change_id>`) if (c.updated) console.log(` <updated>${c.updated}</updated>`) if (c.owner?.name) console.log(` <owner>${c.owner.name}</owner>`) console.log(` </change>`) } console.log(`</changes>`) return } if (limited.length === 0) { console.log( chalk.dim(options.reviewer ? 'No changes need your review.' : 'No changes found.'), ) return } if (options.detailed) { for (const [i, change] of limited.entries()) { if (i > 0) console.log('') renderDetailed(change) } return } const showOwner = options.reviewer === true console.log('') renderTableHeader(showOwner) for (const change of limited) { renderTableRow(change, showOwner) } console.log('') })