@aaronshaf/ger
Version:
Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS
285 lines (244 loc) • 8.95 kB
text/typescript
import { Console, Effect } from 'effect'
import chalk from 'chalk'
import type { ChangeInfo } from '@/schemas/gerrit'
import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
import { writeFileSync } from 'node:fs'
import { escapeXML } from '@/utils/shell-safety'
export interface AnalyzeOptions {
startDate?: string
endDate?: string
repo?: string
json?: boolean
xml?: boolean
markdown?: boolean
csv?: boolean
output?: string
}
export type AnalyzeErrors = ConfigError | ApiError | Error
interface RepoStats {
name: string
count: number
}
interface AuthorStats {
name: string
email: string
count: number
}
interface MonthStats {
month: string
count: number
}
interface AnalyticsResult {
totalMerged: number
dateRange: { start: string; end: string }
byRepo: readonly RepoStats[]
byAuthor: readonly AuthorStats[]
timeline: readonly MonthStats[]
}
const getDefaultStartDate = (): string => {
const d = new Date()
return `${d.getFullYear()}-01-01`
}
const getDefaultEndDate = (): string => new Date().toISOString().slice(0, 10)
const getChangeMonth = (change: ChangeInfo): string => {
const dateStr = change.submitted ?? change.updated ?? change.created ?? ''
return dateStr.slice(0, 7) // YYYY-MM
}
const aggregateByRepo = (changes: readonly ChangeInfo[]): readonly RepoStats[] => {
const counts = new Map<string, number>()
for (const c of changes) {
counts.set(c.project, (counts.get(c.project) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
}
const aggregateByAuthor = (changes: readonly ChangeInfo[]): readonly AuthorStats[] => {
const counts = new Map<string, { name: string; email: string; count: number }>()
for (const c of changes) {
const owner = c.owner
if (!owner) continue
const key = owner.email ?? owner.name ?? String(owner._account_id)
const existing = counts.get(key)
if (existing) {
existing.count++
} else {
counts.set(key, {
name: owner.name ?? 'Unknown',
email: owner.email ?? '',
count: 1,
})
}
}
return [...counts.values()].sort((a, b) => b.count - a.count)
}
const aggregateByMonth = (changes: readonly ChangeInfo[]): readonly MonthStats[] => {
const counts = new Map<string, number>()
for (const c of changes) {
const month = getChangeMonth(c)
counts.set(month, (counts.get(month) ?? 0) + 1)
}
return [...counts.entries()]
.map(([month, count]) => ({ month, count }))
.sort((a, b) => a.month.localeCompare(b.month))
}
const BAR_WIDTH = 30
const renderBar = (count: number, max: number): string => {
const filled = max > 0 ? Math.round((count / max) * BAR_WIDTH) : 0
return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
}
const renderTerminal = (result: AnalyticsResult): string => {
const lines: string[] = []
lines.push('')
lines.push(chalk.bold.cyan(' Contribution Analytics'))
lines.push(chalk.dim(` ${result.dateRange.start} → ${result.dateRange.end}`))
lines.push('')
lines.push(chalk.bold(` Total merged: ${chalk.green(String(result.totalMerged))}`))
lines.push('')
// By Repo
lines.push(chalk.bold.yellow(' ── Changes by Repository ──'))
lines.push('')
const maxRepo = result.byRepo[0]?.count ?? 1
for (const r of result.byRepo.slice(0, 15)) {
const bar = renderBar(r.count, maxRepo)
const name = r.name.length > 35 ? `...${r.name.slice(-32)}` : r.name
lines.push(
` ${chalk.cyan(name.padEnd(35))} ${chalk.green(bar)} ${chalk.bold(String(r.count))}`,
)
}
lines.push('')
// By Author
lines.push(chalk.bold.yellow(' ── Changes by Author ──'))
lines.push('')
const maxAuthor = result.byAuthor[0]?.count ?? 1
for (const a of result.byAuthor.slice(0, 10)) {
const bar = renderBar(a.count, maxAuthor)
const label = a.name.length > 25 ? `${a.name.slice(0, 22)}...` : a.name
lines.push(
` ${chalk.magenta(label.padEnd(25))} ${chalk.green(bar)} ${chalk.bold(String(a.count))}`,
)
}
lines.push('')
// Timeline
lines.push(chalk.bold.yellow(' ── Timeline ──'))
lines.push('')
const maxMonth = Math.max(...result.timeline.map((m) => m.count), 1)
for (const m of result.timeline) {
const bar = renderBar(m.count, maxMonth)
lines.push(` ${chalk.blue(m.month)} ${chalk.green(bar)} ${chalk.bold(String(m.count))}`)
}
lines.push('')
return lines.join('\n')
}
const renderMarkdown = (result: AnalyticsResult): string => {
const lines: string[] = []
lines.push(`# Contribution Analytics`)
lines.push(``)
lines.push(`**Date range:** ${result.dateRange.start} → ${result.dateRange.end}`)
lines.push(`**Total merged:** ${result.totalMerged}`)
lines.push(``)
lines.push(`## Changes by Repository`)
lines.push(``)
lines.push(`| Repository | Count |`)
lines.push(`|---|---|`)
for (const r of result.byRepo) lines.push(`| ${r.name} | ${r.count} |`)
lines.push(``)
lines.push(`## Changes by Author`)
lines.push(``)
lines.push(`| Author | Email | Count |`)
lines.push(`|---|---|---|`)
for (const a of result.byAuthor) lines.push(`| ${a.name} | ${a.email} | ${a.count} |`)
lines.push(``)
lines.push(`## Timeline`)
lines.push(``)
lines.push(`| Month | Count |`)
lines.push(`|---|---|`)
for (const m of result.timeline) lines.push(`| ${m.month} | ${m.count} |`)
lines.push(``)
return lines.join('\n')
}
const csvField = (v: string): string => `"${v.replace(/"/g, '""')}"`
const renderCsv = (result: AnalyticsResult): string => {
const lines: string[] = []
lines.push('section,key,count')
for (const r of result.byRepo) lines.push(`repo,${csvField(r.name)},${r.count}`)
for (const a of result.byAuthor)
lines.push(`author,${csvField(`${a.name} <${a.email}>`)},${a.count}`)
for (const m of result.timeline) lines.push(`timeline,${csvField(m.month)},${m.count}`)
return lines.join('\n')
}
const renderXml = (result: AnalyticsResult): string => {
const lines: string[] = []
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
lines.push(`<analytics>`)
lines.push(` <total_merged>${result.totalMerged}</total_merged>`)
lines.push(` <date_range start="${result.dateRange.start}" end="${result.dateRange.end}"/>`)
lines.push(` <by_repo>`)
for (const r of result.byRepo) {
lines.push(` <repo name="${escapeXML(r.name)}" count="${r.count}"/>`)
}
lines.push(` </by_repo>`)
lines.push(` <by_author>`)
for (const a of result.byAuthor) {
const escaped = a.name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
lines.push(` <author name="${escaped}" email="${a.email}" count="${a.count}"/>`)
}
lines.push(` </by_author>`)
lines.push(` <timeline>`)
for (const m of result.timeline) {
lines.push(` <month value="${m.month}" count="${m.count}"/>`)
}
lines.push(` </timeline>`)
lines.push(`</analytics>`)
return lines.join('\n')
}
export const analyzeCommand = (
options: AnalyzeOptions,
): Effect.Effect<void, AnalyzeErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
Effect.gen(function* () {
const startDate = options.startDate ?? getDefaultStartDate()
const endDate = options.endDate ?? getDefaultEndDate()
const _configService = yield* ConfigService
const apiService = yield* GerritApiService
const isMachineReadable =
options.json || options.xml || options.markdown || options.csv || options.output
if (!isMachineReadable) {
yield* Console.log(chalk.dim(`Fetching merged changes from ${startDate} to ${endDate}...`))
}
const changes = yield* apiService.fetchMergedChanges({
after: startDate,
before: endDate,
repo: options.repo,
})
const result: AnalyticsResult = {
totalMerged: changes.length,
dateRange: { start: startDate, end: endDate },
byRepo: aggregateByRepo(changes),
byAuthor: aggregateByAuthor(changes),
timeline: aggregateByMonth(changes),
}
let output: string
if (options.json) {
output = JSON.stringify(result, null, 2)
} else if (options.xml) {
output = renderXml(result)
} else if (options.markdown) {
output = renderMarkdown(result)
} else if (options.csv) {
output = renderCsv(result)
} else {
output = renderTerminal(result)
}
if (options.output) {
const outputPath = options.output
yield* Effect.try({
try: () => writeFileSync(outputPath, output, 'utf8'),
catch: (e) =>
new Error(`Failed to write output file: ${e instanceof Error ? e.message : String(e)}`),
})
yield* Console.log(chalk.green(`✓ Output written to ${options.output}`))
} else {
yield* Console.log(output)
}
})