diff --git a/src/cli/commands/analyze.ts b/src/cli/commands/analyze.ts index fd56cdd..c1959d0 100644 --- a/src/cli/commands/analyze.ts +++ b/src/cli/commands/analyze.ts @@ -22,6 +22,7 @@ import { import { printTrendReport } from './report/trend-printer' import { printTeamAnalysis } from './report/printers/user-analysis-printer' import { ensureCommitSamples } from '../common/commit-guard' +import { exportReport, ExportData, ExportFormat } from '../../utils/exporter' type TimeRangeMode = 'all-time' | 'custom' | 'auto-last-commit' | 'fallback' @@ -170,12 +171,13 @@ export class AnalyzeExecutor { const isOpenSource = classification.projectType === ProjectType.OPEN_SOURCE // ========== 步骤 4: 月度趋势分析 ========== + let trendResult: Awaited> | undefined // 只有在分析时间跨度超过1个月时才显示趋势分析 if (effectiveSince && effectiveUntil && shouldShowTrendAnalysis(effectiveSince, effectiveUntil)) { console.log() const trendSpinner = ora('📈 正在进行月度趋势分析...').start() try { - const trendResult = await TrendAnalyzer.analyzeTrend( + trendResult = await TrendAnalyzer.analyzeTrend( path, effectiveSince, effectiveUntil, @@ -195,11 +197,12 @@ export class AnalyzeExecutor { } // ========== 步骤 5: 团队工作模式分析 ========== + let teamResult: Awaited> | undefined // 开源项目不显示团队工作模式分析 if (!isOpenSource && GitTeamAnalyzer.shouldAnalyzeTeam(options)) { try { const maxUsers = options.maxUsers ? parseInt(String(options.maxUsers), 10) : 30 - const teamAnalysis = await GitTeamAnalyzer.analyzeTeam( + teamResult = await GitTeamAnalyzer.analyzeTeam( collectOptions, result.index996, 20, // minCommits @@ -207,8 +210,8 @@ export class AnalyzeExecutor { false // silent ) - if (teamAnalysis) { - printTeamAnalysis(teamAnalysis) + if (teamResult) { + printTeamAnalysis(teamResult) } } catch (error) { console.log(chalk.yellow('⚠️ 团队分析失败:'), (error as Error).message) @@ -224,6 +227,43 @@ export class AnalyzeExecutor { console.log(chalk.yellow(warningMessage)) } } + + // ========== 步骤 7: 导出报告 ========== + if (options.export) { + const format = options.export.toLowerCase() as ExportFormat + if (format !== 'json' && format !== 'markdown') { + console.error(chalk.red('❌ 不支持的导出格式:'), options.export, chalk.gray('(仅支持 json 或 markdown)')) + return + } + const repoName = path.split('/').filter(Boolean).pop() || 'unknown' + const defaultExt = format === 'json' ? 'json' : 'md' + const defaultName = `report.${defaultExt}` + const outputPath = options.output || defaultName + + const exportData: ExportData = { + repoName, + repoPath: path, + generatedAt: new Date().toISOString(), + options: { + since: effectiveSince, + until: effectiveUntil, + self: options.self, + hours: options.hours, + halfHour: options.halfHour, + ignoreAuthor: options.ignoreAuthor, + ignoreMsg: options.ignoreMsg, + timezone: options.timezone, + }, + result, + parsedData, + rawData, + classification, + trendResult, + teamAnalysis: teamResult ?? undefined, + } + + exportReport(exportData, format, outputPath) + } } catch (error) { console.error(chalk.red('❌ 分析失败:'), (error as Error).message) process.exit(1) diff --git a/src/cli/commands/multi.ts b/src/cli/commands/multi.ts index 78bd981..0aeff77 100644 --- a/src/cli/commands/multi.ts +++ b/src/cli/commands/multi.ts @@ -25,6 +25,7 @@ import { } from './report' import { printTrendReport } from './report/trend-printer' import { printTeamAnalysis } from './report/printers/user-analysis-printer' +import { exportReport, ExportData, ExportFormat, MultiExportData } from '../../utils/exporter' /** * 判断是否应该启用节假日调休模式 @@ -317,6 +318,7 @@ export class MultiExecutor { MultiComparisonPrinter.print(repoRecords) // ========== 步骤 8: 月度趋势分析(默认启用) ========== + let trendResult: Awaited> | undefined if (selectedRepos.length > 0) { console.log() const trendSpinner = ora('📈 正在进行多仓库汇总月度趋势分析...').start() @@ -330,7 +332,7 @@ export class MultiExecutor { trendSpinner.warn('没有成功的仓库数据,跳过趋势分析') } else { // 使用新的多仓库汇总趋势分析方法 - const trendResult = await TrendAnalyzer.analyzeMultiRepoTrend( + trendResult = await TrendAnalyzer.analyzeMultiRepoTrend( successfulRepoPaths, effectiveSince ?? null, effectiveUntil ?? null, @@ -353,6 +355,7 @@ export class MultiExecutor { // ========== 步骤 9: 团队工作模式分析(聚合所有仓库的数据)========== // 开源项目不显示团队工作模式分析 + let teamResult: Awaited> | undefined if (!hasOpenSourceProject && GitTeamAnalyzer.shouldAnalyzeTeam(options) && selectedRepos.length > 0) { // 收集所有成功分析的仓库路径 const successfulRepoPaths = selectedRepos @@ -374,7 +377,7 @@ export class MultiExecutor { } const maxUsers = options.maxUsers ? parseInt(String(options.maxUsers), 10) : 30 - const teamAnalysis = await MultiRepoTeamAnalyzer.analyzeAggregatedTeam( + teamResult = await MultiRepoTeamAnalyzer.analyzeAggregatedTeam( successfulRepoPaths, collectOptions, 20, // minCommits(所有仓库总计≥20) @@ -382,8 +385,8 @@ export class MultiExecutor { result.index996 // 整体996指数 ) - if (teamAnalysis) { - printTeamAnalysis(teamAnalysis) + if (teamResult) { + printTeamAnalysis(teamResult) } } catch (error) { console.log(chalk.yellow('⚠️ 团队分析失败:'), (error as Error).message) @@ -400,6 +403,66 @@ export class MultiExecutor { console.log(chalk.yellow(warningMessage)) } } + + // ========== 步骤 11: 导出报告 ========== + if (options.export) { + const format = options.export.toLowerCase() as ExportFormat + if (format !== 'json' && format !== 'markdown') { + console.error(chalk.red('❌ 不支持的导出格式:'), options.export, chalk.gray('(仅支持 json 或 markdown)')) + return + } + const defaultExt = format === 'json' ? 'json' : 'md' + const defaultName = `report.${defaultExt}` + const outputPath = options.output || defaultName + + const successfulRepoRecords = repoRecords.filter((r) => r.status === 'success') + const repoExportPromises = successfulRepoRecords.map(async (record) => { + const shouldEnableHolidayRepo = shouldEnableHolidayMode(record.data, options) + const parsedRepoData = await GitParser.parseGitData( + record.data, + options.hours, + effectiveSince, + effectiveUntil, + shouldEnableHolidayRepo.enabled + ) + return { + repoName: record.repo.name, + repoPath: record.repo.path, + generatedAt: new Date().toISOString(), + options: { + since: effectiveSince, + until: effectiveUntil, + self: options.self, + hours: options.hours, + }, + result: record.result, + parsedData: parsedRepoData, + rawData: record.data, + classification: record.classification, + } as ExportData + }) + + const resolvedRepos = await Promise.all(repoExportPromises) + + const multiExportData: MultiExportData = { + generatedAt: new Date().toISOString(), + options: { + since: effectiveSince, + until: effectiveUntil, + self: options.self, + hours: options.hours, + }, + repos: resolvedRepos, + mergedResult: result, + mergedParsedData: parsedData, + mergedRawData: mergedData, + repoRecords: successfulRepoRecords, + trendResult, + teamAnalysis: teamResult ?? undefined, + } + + exportReport(multiExportData, format, outputPath) + } } catch (error) { console.error(chalk.red('❌ 多仓库分析失败:'), (error as Error).message) process.exit(1) diff --git a/src/cli/index.ts b/src/cli/index.ts index d1305ae..ba38c62 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -51,6 +51,8 @@ export class CLIManager { .option('--cn', '强制开启中国节假日调休模式(自动检测 +0800 时区)') .option('--skip-user-analysis', '跳过团队工作模式分析') .option('--max-users ', '最大分析用户数(默认30)', '30') + .option('-e, --export ', '导出报告格式 (json 或 markdown)') + .option('-o, --output ', '导出文件路径 (默认: report.json 或 report.md)') .action(async (paths: string[], options: AnalyzeOptions, command: Command) => { const mergedOptions = this.mergeGlobalOptions(options) @@ -180,6 +182,8 @@ export class CLIManager { ignoreAuthor: options.ignoreAuthor ?? globalOpts.ignoreAuthor, ignoreMsg: options.ignoreMsg ?? globalOpts.ignoreMsg, timezone: options.timezone ?? globalOpts.timezone, + export: options.export ?? globalOpts.export, + output: options.output ?? globalOpts.output, } } @@ -325,6 +329,10 @@ ${chalk.bold('分析选项:')} --ignore-author 排除匹配的作者 (例如: bot|jenkins) --ignore-msg 排除匹配的提交消息 (例如: merge|lint) +${chalk.bold('导出选项:')} + -e, --export 导出报告格式 (json 或 markdown) + -o, --output 导出文件路径 (默认: report.json 或 report.md) + ${chalk.bold('默认策略:')} 自动以最后一次提交为基准,回溯365天进行分析 @@ -347,6 +355,11 @@ ${chalk.bold('示例:')} code996 --ignore-msg "^Merge" # 排除所有以 "Merge" 开头的提交消息 code996 --ignore-msg "merge|lint|format" # 排除多个关键词 + ${chalk.gray('# 导出报告')} + code996 -e json # 导出 JSON 格式 + code996 -e markdown # 导出 Markdown 格式 + code996 -e json -o data.json # 导出到指定路径 + ${chalk.bold('正则表达式语法说明:')} - 使用 | 分隔多个模式 (例如: bot|jenkins) - 使用 ^ 匹配开头 (例如: ^Merge) diff --git a/src/types/git-types.ts b/src/types/git-types.ts index d79bc5f..a85db74 100644 --- a/src/types/git-types.ts +++ b/src/types/git-types.ts @@ -250,6 +250,8 @@ export interface AnalyzeOptions { skipUserAnalysis?: boolean // 是否跳过团队工作模式分析 maxUsers?: number // 最大分析用户数(默认30) cn?: boolean // 强制开启中国节假日调休模式 + export?: string // 导出格式 (json 或 markdown) + output?: string // 导出文件路径 } /** diff --git a/src/utils/exporter.ts b/src/utils/exporter.ts new file mode 100644 index 0000000..633221e --- /dev/null +++ b/src/utils/exporter.ts @@ -0,0 +1,361 @@ +import fs from 'fs' +import path from 'path' +import chalk from 'chalk' +import { + ParsedGitData, + Result996, + GitLogData, + TrendAnalysisResult, + TeamAnalysis, + RepoAnalysisRecord, +} from '../types/git-types' +import { ProjectClassificationResult } from '../core/project-classifier' + +export type ExportFormat = 'json' | 'markdown' + +export interface ExportData { + repoName: string + repoPath: string + generatedAt: string + options: Record + result: Result996 + parsedData: ParsedGitData + rawData: GitLogData + classification?: ProjectClassificationResult + trendResult?: TrendAnalysisResult + teamAnalysis?: TeamAnalysis +} + +export interface MultiExportData { + generatedAt: string + options: Record + repos: ExportData[] + mergedResult: Result996 + mergedParsedData: ParsedGitData + mergedRawData: GitLogData + repoRecords: RepoAnalysisRecord[] + trendResult?: TrendAnalysisResult + teamAnalysis?: TeamAnalysis +} + +/** + * 导出分析报告 + */ +export function exportReport( + data: ExportData | MultiExportData, + format: ExportFormat, + outputPath: string +): void { + const dir = path.dirname(outputPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + const content = format === 'json' ? exportToJson(data) : exportToMarkdown(data) + fs.writeFileSync(outputPath, content, 'utf-8') + console.log() + console.log(chalk.green(`✓ 报告已导出: ${chalk.bold(outputPath)}`)) +} + +/** + * 导出为 JSON 格式 + */ +function exportToJson(data: ExportData | MultiExportData): string { + const isMulti = 'repos' in data + if (isMulti) { + return JSON.stringify(serializeMultiData(data), null, 2) + } + return JSON.stringify(serializeSingleData(data), null, 2) +} + +function serializeSingleData(data: ExportData): Record { + return { + repoName: data.repoName, + repoPath: data.repoPath, + generatedAt: data.generatedAt, + options: data.options, + classification: data.classification, + result: { + index996: data.result.index996, + index996Str: data.result.index996Str, + overTimeRadio: data.result.overTimeRadio, + }, + workTime: data.parsedData.detectedWorkTime, + timeDistribution: { + byHour: data.parsedData.hourData, + byDay: data.parsedData.dayData, + }, + overtime: { + workHour: data.parsedData.workHourPl, + workWeek: data.parsedData.workWeekPl, + weekday: data.parsedData.weekdayOvertime, + weekend: data.parsedData.weekendOvertime, + lateNight: data.parsedData.lateNightAnalysis, + }, + trend: data.trendResult, + teamAnalysis: serializeTeamAnalysis(data.teamAnalysis), + stats: { + totalCommits: data.rawData.totalCommits, + contributors: data.rawData.contributors, + firstCommitDate: data.rawData.firstCommitDate, + lastCommitDate: data.rawData.lastCommitDate, + }, + } +} + +function serializeMultiData(data: MultiExportData): Record { + return { + generatedAt: data.generatedAt, + options: data.options, + summary: { + result: { + index996: data.mergedResult.index996, + index996Str: data.mergedResult.index996Str, + overTimeRadio: data.mergedResult.overTimeRadio, + }, + totalCommits: data.mergedRawData.totalCommits, + repoCount: data.repos.length, + }, + repos: data.repos.map((repo) => serializeSingleData(repo)), + trend: data.trendResult, + teamAnalysis: serializeTeamAnalysis(data.teamAnalysis), + } +} + +function serializeTeamAnalysis(team?: TeamAnalysis): Record | undefined { + if (!team) return undefined + return { + totalContributors: team.totalContributors, + totalAnalyzed: team.totalAnalyzed, + baselineEndHour: team.baselineEndHour, + distribution: { + normal: team.distribution.normal.length, + moderate: team.distribution.moderate.length, + heavy: team.distribution.heavy.length, + }, + statistics: team.statistics, + healthAssessment: team.healthAssessment, + contributors: team.coreContributors.map((u) => ({ + author: u.author, + totalCommits: u.totalCommits, + commitPercentage: u.commitPercentage, + index996: u.index996, + intensityLevel: u.intensityLevel, + workingHours: u.workingHours, + })), + } +} + +/** + * 导出为 Markdown 格式 + */ +function exportToMarkdown(data: ExportData | MultiExportData): string { + const isMulti = 'repos' in data + if (isMulti) { + return buildMultiMarkdown(data) + } + return buildSingleMarkdown(data) +} + +function buildSingleMarkdown(data: ExportData): string { + const lines: string[] = [] + + lines.push(`# ${data.repoName} - 996 指数分析报告`) + lines.push('') + lines.push(`> 生成时间: ${data.generatedAt}`) + lines.push('') + + // 核心结果 + lines.push('## 核心结果') + lines.push('') + lines.push(`| 指标 | 值 |`) + lines.push(`|------|-----|`) + lines.push(`| 996 指数 | ${data.result.index996.toFixed(1)} |`) + lines.push(`| 加班占比 | ${data.result.overTimeRadio.toFixed(1)}% |`) + lines.push(`| 项目类型 | ${data.classification?.projectType ?? '未知'} (置信度: ${data.classification?.confidence}%) |`) + lines.push(`| 总提交数 | ${data.rawData.totalCommits} |`) + lines.push(`| 贡献者数 | ${data.rawData.contributors ?? '-'} |`) + if (data.rawData.firstCommitDate) { + lines.push(`| 首次提交 | ${data.rawData.firstCommitDate} |`) + } + if (data.rawData.lastCommitDate) { + lines.push(`| 最后提交 | ${data.rawData.lastCommitDate} |`) + } + lines.push('') + + // 工作时间 + const wt = data.parsedData.detectedWorkTime + if (wt) { + lines.push('## 工作时间') + lines.push('') + lines.push(`| 指标 | 值 |`) + lines.push(`|------|-----|`) + lines.push(`| 上班时间 | ${formatHour(wt.startHour)} |`) + lines.push(`| 下班时间 | ${formatHour(wt.endHour)} |`) + lines.push(`| 可靠性 | ${wt.isReliable ? '可靠' : '不可靠'} |`) + lines.push(`| 方法 | ${wt.detectionMethod} |`) + lines.push('') + } + + // 加班分析 + lines.push('## 加班分析') + lines.push('') + lines.push(`| 时段 | 提交数 |`) + lines.push(`|------|--------|`) + lines.push(`| 标准工作时间内 | ${data.parsedData.workHourPl[0].count} |`) + lines.push(`| 加班时段 | ${data.parsedData.workHourPl[1].count} |`) + lines.push(`| 工作日 | ${data.parsedData.workWeekPl[0].count} |`) + lines.push(`| 周末 | ${data.parsedData.workWeekPl[1].count} |`) + lines.push('') + + if (data.parsedData.weekdayOvertime) { + const wd = data.parsedData.weekdayOvertime + lines.push('### 各工作日加班分布') + lines.push('') + lines.push(`| 周一 | 周二 | 周三 | 周四 | 周五 |`) + lines.push(`|------|------|------|------|------|`) + lines.push(`| ${wd.monday} | ${wd.tuesday} | ${wd.wednesday} | ${wd.thursday} | ${wd.friday} |`) + lines.push('') + } + + if (data.parsedData.weekendOvertime) { + const we = data.parsedData.weekendOvertime + lines.push('### 周末加班分布') + lines.push('') + lines.push(`| 指标 | 天数 |`) + lines.push(`|------|------|`) + lines.push(`| 周六 | ${we.saturdayDays} |`) + lines.push(`| 周日 | ${we.sundayDays} |`) + lines.push(`| 真正加班(≥3次) | ${we.realOvertimeDays} |`) + lines.push('') + } + + if (data.parsedData.lateNightAnalysis) { + const ln = data.parsedData.lateNightAnalysis + lines.push('### 深夜加班分析') + lines.push('') + lines.push(`| 时段 | 提交数 |`) + lines.push(`|------|--------|`) + lines.push(`| 晚间(下班-21:00) | ${ln.evening} |`) + lines.push(`| 加班晚期(21:00-23:00) | ${ln.lateNight} |`) + lines.push(`| 深夜(23:00-02:00) | ${ln.midnight} |`) + lines.push(`| 凌晨(02:00-06:00) | ${ln.dawn} |`) + lines.push(`| 深夜天数 | ${ln.midnightDays} |`) + lines.push(`| 深夜占比 | ${ln.midnightRate.toFixed(1)}% |`) + lines.push('') + } + + // 时间分布 + lines.push('## 24小时提交分布') + lines.push('') + lines.push('```') + for (const item of data.parsedData.hourData) { + const bar = '█'.repeat(Math.max(1, Math.round(item.count / Math.max(1, getMaxCount(data.parsedData.hourData) / 20)))) + lines.push(`${item.time} | ${bar} ${item.count}`) + } + lines.push('```') + lines.push('') + + // 趋势 + if (data.trendResult && data.trendResult.monthlyData.length > 0) { + lines.push('## 月度趋势') + lines.push('') + lines.push(`**整体趋势**: ${trendLabel(data.trendResult.summary.trend)}`) + lines.push(`**平均 996 指数**: ${data.trendResult.summary.avgIndex996.toFixed(1)}`) + lines.push(`**平均工作跨度**: ${data.trendResult.summary.avgWorkSpan.toFixed(1)} 小时`) + lines.push('') + lines.push('| 月份 | 996指数 | 平均工时 | 提交数 | 趋势 |') + lines.push('|------|---------|----------|--------|------|') + for (const m of data.trendResult.monthlyData) { + lines.push(`| ${m.month} | ${m.index996.toFixed(1)} | ${m.avgWorkSpan.toFixed(1)}h | ${m.totalCommits} | ${m.confidence} |`) + } + lines.push('') + } + + // 团队分析 + if (data.teamAnalysis) { + lines.push('## 团队工作模式') + lines.push('') + const ta = data.teamAnalysis + lines.push(`**团队中位数 996 指数**: ${ta.statistics.median996.toFixed(1)}`) + lines.push(`**工作强度分布**: 正常 ${ta.distribution.normal.length} 人, 适度 ${ta.distribution.moderate.length} 人, 重度 ${ta.distribution.heavy.length} 人`) + lines.push('') + lines.push('| 成员 | 提交数 | 占比 | 996指数 | 强度 |') + lines.push('|------|--------|------|---------|------|') + for (const u of ta.coreContributors) { + lines.push(`| ${u.author} | ${u.totalCommits} | ${u.commitPercentage.toFixed(1)}% | ${u.index996?.toFixed(1) ?? '-'} | ${u.intensityLevel ?? '-'} |`) + } + lines.push('') + } + + return lines.join('\n') +} + +function buildMultiMarkdown(data: MultiExportData): string { + const lines: string[] = [] + + lines.push('# 多仓库 996 指数分析报告') + lines.push('') + lines.push(`> 生成时间: ${data.generatedAt}`) + lines.push('') + + // 汇总 + lines.push('## 汇总结果') + lines.push('') + lines.push(`| 指标 | 值 |`) + lines.push(`|------|-----|`) + lines.push(`| 合并 996 指数 | ${data.mergedResult.index996.toFixed(1)} |`) + lines.push(`| 合并加班占比 | ${data.mergedResult.overTimeRadio.toFixed(1)}% |`) + lines.push(`| 总提交数 | ${data.mergedRawData.totalCommits} |`) + lines.push(`| 仓库数 | ${data.repos.length} |`) + lines.push('') + + // 各仓库对比 + lines.push('## 各仓库对比') + lines.push('') + lines.push('| 仓库 | 996指数 | 提交数 | 状态 |') + lines.push('|------|---------|--------|------|') + for (const repo of data.repos) { + lines.push(`| ${repo.repoName} | ${repo.result.index996.toFixed(1)} | ${repo.rawData.totalCommits} | ✓ |`) + } + lines.push('') + + // 详细报告 + lines.push('## 详细报告') + lines.push('') + for (const repo of data.repos) { + lines.push(`---`) + lines.push('') + lines.push(`### ${repo.repoName}`) + lines.push('') + // 复用单仓库的主体内容 + const repoMd = buildSingleMarkdown(repo) + // 去掉标题头(已用自己的 ### ) + const body = repoMd.split('\n').slice(3).join('\n') + lines.push(body) + lines.push('') + } + + return lines.join('\n') +} + +function formatHour(hour: number): string { + const h = Math.floor(hour) + const m = Math.round((hour - h) * 60) + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` +} + +function getMaxCount(data: Array<{ count: number }>): number { + return Math.max(...data.map((d) => d.count), 1) +} + +function trendLabel(trend: string): string { + switch (trend) { + case 'increasing': + return '上升' + case 'decreasing': + return '下降' + default: + return '稳定' + } +}