@spaceflow/review-summary 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +591 -0
- package/README.md +58 -0
- package/dist/index.js +73 -0
- package/package.json +32 -0
- package/src/index.ts +28 -0
- package/src/locales/en/review-summary.json +13 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/review-summary.json +13 -0
- package/src/review-summary.service.ts +528 -0
- package/src/types.ts +115 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { GitProviderService, shouldLog, normalizeVerbose } from "@spaceflow/core";
|
|
2
|
+
import type { IConfigReader } from "@spaceflow/core";
|
|
3
|
+
import type { PullRequest, Issue, CiConfig } from "@spaceflow/core";
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type {
|
|
7
|
+
PeriodSummaryOptions,
|
|
8
|
+
PeriodSummaryContext,
|
|
9
|
+
PeriodSummaryResult,
|
|
10
|
+
PrStats,
|
|
11
|
+
UserStats,
|
|
12
|
+
OutputTarget,
|
|
13
|
+
TimePreset,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
/** 分数权重配置 */
|
|
17
|
+
const SCORE_WEIGHTS = {
|
|
18
|
+
/** 每个 PR 的基础分 */
|
|
19
|
+
prBase: 10,
|
|
20
|
+
/** 每 100 行新增代码的分数 */
|
|
21
|
+
additionsPer100: 2,
|
|
22
|
+
/** 每 100 行删除代码的分数 */
|
|
23
|
+
deletionsPer100: 1,
|
|
24
|
+
/** 每个变更文件的分数 */
|
|
25
|
+
changedFile: 0.5,
|
|
26
|
+
/** 每个未修复问题的扣分 */
|
|
27
|
+
issueDeduction: 3,
|
|
28
|
+
/** 每个已修复问题的加分 */
|
|
29
|
+
fixedBonus: 1,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 周期统计服务
|
|
34
|
+
*/
|
|
35
|
+
export class PeriodSummaryService {
|
|
36
|
+
constructor(
|
|
37
|
+
protected readonly gitProvider: GitProviderService,
|
|
38
|
+
protected readonly config: IConfigReader,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从配置和选项获取执行上下文
|
|
43
|
+
*/
|
|
44
|
+
private getContextFromOptions(options: PeriodSummaryOptions): PeriodSummaryContext {
|
|
45
|
+
let owner: string;
|
|
46
|
+
let repo: string;
|
|
47
|
+
if (options.repository) {
|
|
48
|
+
const parts = options.repository.split("/");
|
|
49
|
+
if (parts.length !== 2) {
|
|
50
|
+
throw new Error(`仓库格式不正确,期望 "owner/repo",实际: "${options.repository}"`);
|
|
51
|
+
}
|
|
52
|
+
owner = parts[0];
|
|
53
|
+
repo = parts[1];
|
|
54
|
+
} else {
|
|
55
|
+
const ciConf = this.config.get<CiConfig>("ci");
|
|
56
|
+
const repository = ciConf?.repository;
|
|
57
|
+
if (!repository) {
|
|
58
|
+
throw new Error("缺少仓库配置,请通过 --repository 参数或环境变量 GITHUB_REPOSITORY 指定");
|
|
59
|
+
}
|
|
60
|
+
const parts = repository.split("/");
|
|
61
|
+
owner = parts[0];
|
|
62
|
+
repo = parts[1];
|
|
63
|
+
}
|
|
64
|
+
if (options.ci) {
|
|
65
|
+
this.gitProvider.validateConfig();
|
|
66
|
+
}
|
|
67
|
+
const { since, until } = this.resolveDateRange(options);
|
|
68
|
+
if (since > until) {
|
|
69
|
+
throw new Error("开始日期不能晚于结束日期");
|
|
70
|
+
}
|
|
71
|
+
const output: OutputTarget = options.output ?? "console";
|
|
72
|
+
if (output === "issue" && !options.ci) {
|
|
73
|
+
this.gitProvider.validateConfig();
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
owner,
|
|
77
|
+
repo,
|
|
78
|
+
since,
|
|
79
|
+
until,
|
|
80
|
+
format: options.format ?? (output === "console" ? "table" : "markdown"),
|
|
81
|
+
output,
|
|
82
|
+
outputFile: options.outputFile,
|
|
83
|
+
verbose: normalizeVerbose(options.verbose),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 执行周期统计
|
|
89
|
+
*/
|
|
90
|
+
async execute(context: PeriodSummaryContext): Promise<PeriodSummaryResult> {
|
|
91
|
+
const { owner, repo, since, until, verbose } = context;
|
|
92
|
+
if (shouldLog(verbose, 1)) {
|
|
93
|
+
console.log(`📊 开始统计 ${owner}/${repo} 的 PR 数据...`);
|
|
94
|
+
console.log(`📅 时间范围: ${this.formatDate(since)} ~ ${this.formatDate(until)}`);
|
|
95
|
+
}
|
|
96
|
+
const allPrs = await this.gitProvider.listAllPullRequests(owner, repo, { state: "closed" });
|
|
97
|
+
const mergedPrs = allPrs.filter((pr) => {
|
|
98
|
+
if (!pr.merged_at) return false;
|
|
99
|
+
const mergedAt = new Date(pr.merged_at);
|
|
100
|
+
return mergedAt >= since && mergedAt <= until;
|
|
101
|
+
});
|
|
102
|
+
if (shouldLog(verbose, 1)) {
|
|
103
|
+
console.log(`📝 找到 ${mergedPrs.length} 个已合并的 PR`);
|
|
104
|
+
}
|
|
105
|
+
const prStatsList: PrStats[] = [];
|
|
106
|
+
for (const pr of mergedPrs) {
|
|
107
|
+
if (shouldLog(verbose, 1)) {
|
|
108
|
+
console.log(` 处理 PR #${pr.number}: ${pr.title}`);
|
|
109
|
+
}
|
|
110
|
+
const stats = await this.collectPrStats(owner, repo, pr);
|
|
111
|
+
prStatsList.push(stats);
|
|
112
|
+
}
|
|
113
|
+
const userStatsMap = this.aggregateByUser(prStatsList);
|
|
114
|
+
const sortedUserStats = this.sortUserStats(userStatsMap);
|
|
115
|
+
return {
|
|
116
|
+
period: {
|
|
117
|
+
since: this.formatDate(since),
|
|
118
|
+
until: this.formatDate(until),
|
|
119
|
+
},
|
|
120
|
+
repository: `${owner}/${repo}`,
|
|
121
|
+
totalPrs: mergedPrs.length,
|
|
122
|
+
userStats: sortedUserStats,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 收集单个 PR 的统计数据
|
|
128
|
+
*/
|
|
129
|
+
protected async collectPrStats(owner: string, repo: string, pr: PullRequest): Promise<PrStats> {
|
|
130
|
+
let additions = 0;
|
|
131
|
+
let deletions = 0;
|
|
132
|
+
let changedFiles = 0;
|
|
133
|
+
try {
|
|
134
|
+
const files = await this.gitProvider.getPullRequestFiles(owner, repo, pr.number!);
|
|
135
|
+
changedFiles = files.length;
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
additions += file.additions ?? 0;
|
|
138
|
+
deletions += file.deletions ?? 0;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// 如果获取文件失败,使用默认值
|
|
142
|
+
}
|
|
143
|
+
const { issueCount, fixedCount } = await this.extractIssueStats(owner, repo, pr.number!);
|
|
144
|
+
return {
|
|
145
|
+
number: pr.number!,
|
|
146
|
+
title: pr.title ?? "",
|
|
147
|
+
author: pr.user?.login ?? "unknown",
|
|
148
|
+
mergedAt: pr.merged_at ?? "",
|
|
149
|
+
additions,
|
|
150
|
+
deletions,
|
|
151
|
+
changedFiles,
|
|
152
|
+
issueCount,
|
|
153
|
+
fixedCount,
|
|
154
|
+
description: this.extractDescription(pr),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 从 PR 评论中提取问题统计
|
|
160
|
+
*/
|
|
161
|
+
protected async extractIssueStats(
|
|
162
|
+
owner: string,
|
|
163
|
+
repo: string,
|
|
164
|
+
prNumber: number,
|
|
165
|
+
): Promise<{ issueCount: number; fixedCount: number }> {
|
|
166
|
+
try {
|
|
167
|
+
const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
|
|
168
|
+
let issueCount = 0;
|
|
169
|
+
let fixedCount = 0;
|
|
170
|
+
for (const comment of comments) {
|
|
171
|
+
const body = comment.body ?? "";
|
|
172
|
+
const issueMatch = body.match(/发现\s*(\d+)\s*个问题/);
|
|
173
|
+
if (issueMatch) {
|
|
174
|
+
issueCount = Math.max(issueCount, parseInt(issueMatch[1], 10));
|
|
175
|
+
}
|
|
176
|
+
const fixedMatch = body.match(/已修复[::]\s*(\d+)/);
|
|
177
|
+
if (fixedMatch) {
|
|
178
|
+
fixedCount = Math.max(fixedCount, parseInt(fixedMatch[1], 10));
|
|
179
|
+
}
|
|
180
|
+
const statsMatch = body.match(/🔴\s*(\d+).*🟡\s*(\d+)/);
|
|
181
|
+
if (statsMatch) {
|
|
182
|
+
const errorCount = parseInt(statsMatch[1], 10);
|
|
183
|
+
const warnCount = parseInt(statsMatch[2], 10);
|
|
184
|
+
issueCount = Math.max(issueCount, errorCount + warnCount);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { issueCount, fixedCount };
|
|
188
|
+
} catch {
|
|
189
|
+
return { issueCount: 0, fixedCount: 0 };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 从 PR 提取功能描述
|
|
195
|
+
*/
|
|
196
|
+
protected extractDescription(pr: PullRequest): string {
|
|
197
|
+
if (pr.title) {
|
|
198
|
+
return pr.title.replace(/^\[.*?\]\s*/, "").trim();
|
|
199
|
+
}
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 按用户聚合统计数据
|
|
205
|
+
*/
|
|
206
|
+
protected aggregateByUser(prStatsList: PrStats[]): Map<string, UserStats> {
|
|
207
|
+
const userMap = new Map<string, UserStats>();
|
|
208
|
+
for (const pr of prStatsList) {
|
|
209
|
+
let userStats = userMap.get(pr.author);
|
|
210
|
+
if (!userStats) {
|
|
211
|
+
userStats = {
|
|
212
|
+
username: pr.author,
|
|
213
|
+
prCount: 0,
|
|
214
|
+
totalAdditions: 0,
|
|
215
|
+
totalDeletions: 0,
|
|
216
|
+
totalChangedFiles: 0,
|
|
217
|
+
totalIssues: 0,
|
|
218
|
+
totalFixed: 0,
|
|
219
|
+
score: 0,
|
|
220
|
+
features: [],
|
|
221
|
+
prs: [],
|
|
222
|
+
};
|
|
223
|
+
userMap.set(pr.author, userStats);
|
|
224
|
+
}
|
|
225
|
+
userStats.prCount++;
|
|
226
|
+
userStats.totalAdditions += pr.additions;
|
|
227
|
+
userStats.totalDeletions += pr.deletions;
|
|
228
|
+
userStats.totalChangedFiles += pr.changedFiles;
|
|
229
|
+
userStats.totalIssues += pr.issueCount;
|
|
230
|
+
userStats.totalFixed += pr.fixedCount;
|
|
231
|
+
if (pr.description) {
|
|
232
|
+
userStats.features.push(pr.description);
|
|
233
|
+
}
|
|
234
|
+
userStats.prs.push(pr);
|
|
235
|
+
}
|
|
236
|
+
for (const userStats of userMap.values()) {
|
|
237
|
+
userStats.score = this.calculateScore(userStats);
|
|
238
|
+
}
|
|
239
|
+
return userMap;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 计算用户综合分数
|
|
244
|
+
*/
|
|
245
|
+
protected calculateScore(stats: UserStats): number {
|
|
246
|
+
const prScore = stats.prCount * SCORE_WEIGHTS.prBase;
|
|
247
|
+
const additionsScore = (stats.totalAdditions / 100) * SCORE_WEIGHTS.additionsPer100;
|
|
248
|
+
const deletionsScore = (stats.totalDeletions / 100) * SCORE_WEIGHTS.deletionsPer100;
|
|
249
|
+
const filesScore = stats.totalChangedFiles * SCORE_WEIGHTS.changedFile;
|
|
250
|
+
const unfixedIssues = stats.totalIssues - stats.totalFixed;
|
|
251
|
+
const issueDeduction = unfixedIssues * SCORE_WEIGHTS.issueDeduction;
|
|
252
|
+
const fixedBonus = stats.totalFixed * SCORE_WEIGHTS.fixedBonus;
|
|
253
|
+
const totalScore =
|
|
254
|
+
prScore + additionsScore + deletionsScore + filesScore - issueDeduction + fixedBonus;
|
|
255
|
+
return Math.max(0, Math.round(totalScore * 10) / 10);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 按分数排序用户统计
|
|
260
|
+
*/
|
|
261
|
+
protected sortUserStats(userMap: Map<string, UserStats>): UserStats[] {
|
|
262
|
+
return Array.from(userMap.values()).sort((a, b) => b.score - a.score);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 解析日期字符串
|
|
267
|
+
*/
|
|
268
|
+
protected parseDate(dateStr: string, fieldName: string): Date {
|
|
269
|
+
const date = new Date(dateStr);
|
|
270
|
+
if (isNaN(date.getTime())) {
|
|
271
|
+
throw new Error(`${fieldName}格式不正确: "${dateStr}",请使用 YYYY-MM-DD 格式`);
|
|
272
|
+
}
|
|
273
|
+
return date;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 根据预设或手动输入解析日期范围
|
|
278
|
+
*/
|
|
279
|
+
protected resolveDateRange(options: PeriodSummaryOptions): { since: Date; until: Date } {
|
|
280
|
+
if (options.preset) {
|
|
281
|
+
return this.resolvePresetDateRange(options.preset);
|
|
282
|
+
}
|
|
283
|
+
if (!options.since) {
|
|
284
|
+
throw new Error("请指定 --since 参数或使用 --preset 预设时间范围");
|
|
285
|
+
}
|
|
286
|
+
const since = this.parseDate(options.since, "开始日期");
|
|
287
|
+
const until = options.until ? this.parseDate(options.until, "结束日期") : new Date();
|
|
288
|
+
until.setHours(23, 59, 59, 999);
|
|
289
|
+
return { since, until };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 根据预设解析日期范围
|
|
294
|
+
*/
|
|
295
|
+
protected resolvePresetDateRange(preset: TimePreset): { since: Date; until: Date } {
|
|
296
|
+
const now = new Date();
|
|
297
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
298
|
+
let since: Date;
|
|
299
|
+
let until: Date;
|
|
300
|
+
switch (preset) {
|
|
301
|
+
case "this-week": {
|
|
302
|
+
const dayOfWeek = today.getDay();
|
|
303
|
+
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
304
|
+
since = new Date(today);
|
|
305
|
+
since.setDate(today.getDate() + mondayOffset);
|
|
306
|
+
until = new Date(today);
|
|
307
|
+
until.setHours(23, 59, 59, 999);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "last-week": {
|
|
311
|
+
const dayOfWeek = today.getDay();
|
|
312
|
+
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
313
|
+
until = new Date(today);
|
|
314
|
+
until.setDate(today.getDate() + mondayOffset - 1);
|
|
315
|
+
until.setHours(23, 59, 59, 999);
|
|
316
|
+
since = new Date(until);
|
|
317
|
+
since.setDate(until.getDate() - 6);
|
|
318
|
+
since.setHours(0, 0, 0, 0);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "this-month": {
|
|
322
|
+
since = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
323
|
+
until = new Date(today);
|
|
324
|
+
until.setHours(23, 59, 59, 999);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "last-month": {
|
|
328
|
+
since = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
329
|
+
until = new Date(today.getFullYear(), today.getMonth(), 0);
|
|
330
|
+
until.setHours(23, 59, 59, 999);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "last-7-days": {
|
|
334
|
+
since = new Date(today);
|
|
335
|
+
since.setDate(today.getDate() - 6);
|
|
336
|
+
until = new Date(today);
|
|
337
|
+
until.setHours(23, 59, 59, 999);
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "last-15-days": {
|
|
341
|
+
since = new Date(today);
|
|
342
|
+
since.setDate(today.getDate() - 14);
|
|
343
|
+
until = new Date(today);
|
|
344
|
+
until.setHours(23, 59, 59, 999);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case "last-30-days": {
|
|
348
|
+
since = new Date(today);
|
|
349
|
+
since.setDate(today.getDate() - 29);
|
|
350
|
+
until = new Date(today);
|
|
351
|
+
until.setHours(23, 59, 59, 999);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
default:
|
|
355
|
+
throw new Error(`未知的时间预设: ${preset}`);
|
|
356
|
+
}
|
|
357
|
+
return { since, until };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 格式化日期为 YYYY-MM-DD
|
|
362
|
+
*/
|
|
363
|
+
protected formatDate(date: Date): string {
|
|
364
|
+
return date.toISOString().split("T")[0];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 格式化输出结果
|
|
369
|
+
*/
|
|
370
|
+
formatOutput(result: PeriodSummaryResult, format: "table" | "json" | "markdown"): string {
|
|
371
|
+
switch (format) {
|
|
372
|
+
case "json":
|
|
373
|
+
return JSON.stringify(result, null, 2);
|
|
374
|
+
case "markdown":
|
|
375
|
+
return this.formatMarkdown(result);
|
|
376
|
+
case "table":
|
|
377
|
+
default:
|
|
378
|
+
return this.formatTable(result);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 格式化为表格输出
|
|
384
|
+
*/
|
|
385
|
+
protected formatTable(result: PeriodSummaryResult): string {
|
|
386
|
+
const lines: string[] = [];
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push(`📊 周期统计报告`);
|
|
389
|
+
lines.push(`${"─".repeat(60)}`);
|
|
390
|
+
lines.push(`📦 仓库: ${result.repository}`);
|
|
391
|
+
lines.push(`📅 周期: ${result.period.since} ~ ${result.period.until}`);
|
|
392
|
+
lines.push(`📝 合并 PR 数: ${result.totalPrs}`);
|
|
393
|
+
lines.push("");
|
|
394
|
+
lines.push(`🏆 贡献者排名`);
|
|
395
|
+
lines.push(`${"─".repeat(60)}`);
|
|
396
|
+
const header = [
|
|
397
|
+
"排名".padEnd(4),
|
|
398
|
+
"用户".padEnd(15),
|
|
399
|
+
"PR数".padStart(5),
|
|
400
|
+
"新增".padStart(8),
|
|
401
|
+
"删除".padStart(8),
|
|
402
|
+
"问题".padStart(5),
|
|
403
|
+
"分数".padStart(8),
|
|
404
|
+
].join(" │ ");
|
|
405
|
+
lines.push(header);
|
|
406
|
+
lines.push("─".repeat(60));
|
|
407
|
+
result.userStats.forEach((user, index) => {
|
|
408
|
+
const row = [
|
|
409
|
+
`#${index + 1}`.padEnd(4),
|
|
410
|
+
user.username.slice(0, 15).padEnd(15),
|
|
411
|
+
String(user.prCount).padStart(5),
|
|
412
|
+
`+${user.totalAdditions}`.padStart(8),
|
|
413
|
+
`-${user.totalDeletions}`.padStart(8),
|
|
414
|
+
String(user.totalIssues).padStart(5),
|
|
415
|
+
user.score.toFixed(1).padStart(8),
|
|
416
|
+
].join(" │ ");
|
|
417
|
+
lines.push(row);
|
|
418
|
+
});
|
|
419
|
+
lines.push("─".repeat(60));
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push(`📋 功能摘要`);
|
|
422
|
+
lines.push(`${"─".repeat(60)}`);
|
|
423
|
+
for (const user of result.userStats) {
|
|
424
|
+
if (user.features.length > 0) {
|
|
425
|
+
lines.push(`\n👤 ${user.username}:`);
|
|
426
|
+
for (const feature of user.features) {
|
|
427
|
+
lines.push(` • ${feature}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
lines.push("");
|
|
432
|
+
return lines.join("\n");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 格式化为 Markdown 输出
|
|
437
|
+
*/
|
|
438
|
+
protected formatMarkdown(result: PeriodSummaryResult): string {
|
|
439
|
+
const lines: string[] = [];
|
|
440
|
+
lines.push(`# 📊 周期统计报告`);
|
|
441
|
+
lines.push("");
|
|
442
|
+
lines.push(`- **仓库**: ${result.repository}`);
|
|
443
|
+
lines.push(`- **周期**: ${result.period.since} ~ ${result.period.until}`);
|
|
444
|
+
lines.push(`- **合并 PR 数**: ${result.totalPrs}`);
|
|
445
|
+
lines.push("");
|
|
446
|
+
lines.push(`## 🏆 贡献者排名`);
|
|
447
|
+
lines.push("");
|
|
448
|
+
lines.push(`| 排名 | 用户 | PR数 | 新增 | 删除 | 问题 | 分数 |`);
|
|
449
|
+
lines.push(`|------|------|------|------|------|------|------|`);
|
|
450
|
+
result.userStats.forEach((user, index) => {
|
|
451
|
+
lines.push(
|
|
452
|
+
`| #${index + 1} | ${user.username} | ${user.prCount} | +${user.totalAdditions} | -${user.totalDeletions} | ${user.totalIssues} | ${user.score.toFixed(1)} |`,
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
lines.push("");
|
|
456
|
+
lines.push(`## 📋 功能摘要`);
|
|
457
|
+
lines.push("");
|
|
458
|
+
for (const user of result.userStats) {
|
|
459
|
+
if (user.features.length > 0) {
|
|
460
|
+
lines.push(`### 👤 ${user.username}`);
|
|
461
|
+
lines.push("");
|
|
462
|
+
for (const feature of user.features) {
|
|
463
|
+
lines.push(`- ${feature}`);
|
|
464
|
+
}
|
|
465
|
+
lines.push("");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return lines.join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 输出报告到指定目标
|
|
473
|
+
*/
|
|
474
|
+
async outputReport(
|
|
475
|
+
context: PeriodSummaryContext,
|
|
476
|
+
result: PeriodSummaryResult,
|
|
477
|
+
): Promise<{ type: OutputTarget; location?: string }> {
|
|
478
|
+
const content = this.formatOutput(result, context.format);
|
|
479
|
+
switch (context.output) {
|
|
480
|
+
case "issue":
|
|
481
|
+
return this.outputToIssue(context, result, content);
|
|
482
|
+
case "file":
|
|
483
|
+
return this.outputToFile(context, result, content);
|
|
484
|
+
case "console":
|
|
485
|
+
default:
|
|
486
|
+
console.log(content);
|
|
487
|
+
return { type: "console" };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 输出报告到 GitHub Issue
|
|
493
|
+
*/
|
|
494
|
+
protected async outputToIssue(
|
|
495
|
+
context: PeriodSummaryContext,
|
|
496
|
+
result: PeriodSummaryResult,
|
|
497
|
+
content: string,
|
|
498
|
+
): Promise<{ type: OutputTarget; location: string }> {
|
|
499
|
+
const title = `📊 周期统计报告: ${result.period.since} ~ ${result.period.until}`;
|
|
500
|
+
const issue: Issue = await this.gitProvider.createIssue(context.owner, context.repo, {
|
|
501
|
+
title,
|
|
502
|
+
body: content,
|
|
503
|
+
});
|
|
504
|
+
const location = issue.html_url ?? `#${issue.number}`;
|
|
505
|
+
if (shouldLog(context.verbose, 1)) {
|
|
506
|
+
console.log(`✅ 已创建 Issue: ${location}`);
|
|
507
|
+
}
|
|
508
|
+
return { type: "issue", location };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 输出报告到 Markdown 文件
|
|
513
|
+
*/
|
|
514
|
+
protected outputToFile(
|
|
515
|
+
context: PeriodSummaryContext,
|
|
516
|
+
result: PeriodSummaryResult,
|
|
517
|
+
content: string,
|
|
518
|
+
): { type: OutputTarget; location: string } {
|
|
519
|
+
const filename =
|
|
520
|
+
context.outputFile ?? `period-summary-${result.period.since}-${result.period.until}.md`;
|
|
521
|
+
const filepath = join(process.cwd(), filename);
|
|
522
|
+
writeFileSync(filepath, content, "utf-8");
|
|
523
|
+
if (shouldLog(context.verbose, 1)) {
|
|
524
|
+
console.log(`✅ 已保存到文件: ${filepath}`);
|
|
525
|
+
}
|
|
526
|
+
return { type: "file", location: filepath };
|
|
527
|
+
}
|
|
528
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 周期统计命令类型定义
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { VerboseLevel } from "@spaceflow/core";
|
|
6
|
+
|
|
7
|
+
/** 输出目标类型 */
|
|
8
|
+
export type OutputTarget = "console" | "issue" | "file";
|
|
9
|
+
|
|
10
|
+
/** 时间预设类型 */
|
|
11
|
+
export type TimePreset =
|
|
12
|
+
| "this-week"
|
|
13
|
+
| "last-week"
|
|
14
|
+
| "this-month"
|
|
15
|
+
| "last-month"
|
|
16
|
+
| "last-7-days"
|
|
17
|
+
| "last-15-days"
|
|
18
|
+
| "last-30-days";
|
|
19
|
+
|
|
20
|
+
/** 命令选项 */
|
|
21
|
+
export interface PeriodSummaryOptions {
|
|
22
|
+
/** 开始日期 (YYYY-MM-DD) */
|
|
23
|
+
since?: string;
|
|
24
|
+
/** 结束日期 (YYYY-MM-DD),默认为今天 */
|
|
25
|
+
until?: string;
|
|
26
|
+
/** 时间预设,优先级高于 since/until */
|
|
27
|
+
preset?: TimePreset;
|
|
28
|
+
/** 仓库路径 owner/repo,默认从环境变量获取 */
|
|
29
|
+
repository?: string;
|
|
30
|
+
/** 是否在 CI 环境中运行 */
|
|
31
|
+
ci?: boolean;
|
|
32
|
+
/** 输出格式 */
|
|
33
|
+
format?: "table" | "json" | "markdown";
|
|
34
|
+
/** 输出目标:console(控制台)、issue(创建 GitHub Issue)、file(Markdown 文件) */
|
|
35
|
+
output?: OutputTarget;
|
|
36
|
+
/** 输出文件路径(当 output 为 file 时使用) */
|
|
37
|
+
outputFile?: string;
|
|
38
|
+
/** 详细日志级别 (0: 静默, 1: 过程日志, 2: 详细日志) */
|
|
39
|
+
verbose?: VerboseLevel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 命令执行上下文 */
|
|
43
|
+
export interface PeriodSummaryContext {
|
|
44
|
+
owner: string;
|
|
45
|
+
repo: string;
|
|
46
|
+
since: Date;
|
|
47
|
+
until: Date;
|
|
48
|
+
format: "table" | "json" | "markdown";
|
|
49
|
+
output: OutputTarget;
|
|
50
|
+
outputFile?: string;
|
|
51
|
+
verbose: VerboseLevel;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 单个 PR 的统计数据 */
|
|
55
|
+
export interface PrStats {
|
|
56
|
+
/** PR 编号 */
|
|
57
|
+
number: number;
|
|
58
|
+
/** PR 标题 */
|
|
59
|
+
title: string;
|
|
60
|
+
/** 作者用户名 */
|
|
61
|
+
author: string;
|
|
62
|
+
/** 合并时间 */
|
|
63
|
+
mergedAt: string;
|
|
64
|
+
/** 新增行数 */
|
|
65
|
+
additions: number;
|
|
66
|
+
/** 删除行数 */
|
|
67
|
+
deletions: number;
|
|
68
|
+
/** 变更文件数 */
|
|
69
|
+
changedFiles: number;
|
|
70
|
+
/** 扫描发现的问题数 */
|
|
71
|
+
issueCount: number;
|
|
72
|
+
/** 已修复的问题数 */
|
|
73
|
+
fixedCount: number;
|
|
74
|
+
/** PR 描述/功能摘要 */
|
|
75
|
+
description: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 单个用户的统计数据 */
|
|
79
|
+
export interface UserStats {
|
|
80
|
+
/** 用户名 */
|
|
81
|
+
username: string;
|
|
82
|
+
/** PR 数量 */
|
|
83
|
+
prCount: number;
|
|
84
|
+
/** 总新增行数 */
|
|
85
|
+
totalAdditions: number;
|
|
86
|
+
/** 总删除行数 */
|
|
87
|
+
totalDeletions: number;
|
|
88
|
+
/** 总变更文件数 */
|
|
89
|
+
totalChangedFiles: number;
|
|
90
|
+
/** 总问题数 */
|
|
91
|
+
totalIssues: number;
|
|
92
|
+
/** 总已修复问题数 */
|
|
93
|
+
totalFixed: number;
|
|
94
|
+
/** 综合分数 */
|
|
95
|
+
score: number;
|
|
96
|
+
/** 功能摘要列表 */
|
|
97
|
+
features: string[];
|
|
98
|
+
/** 该用户的 PR 列表 */
|
|
99
|
+
prs: PrStats[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 周期统计结果 */
|
|
103
|
+
export interface PeriodSummaryResult {
|
|
104
|
+
/** 统计周期 */
|
|
105
|
+
period: {
|
|
106
|
+
since: string;
|
|
107
|
+
until: string;
|
|
108
|
+
};
|
|
109
|
+
/** 仓库信息 */
|
|
110
|
+
repository: string;
|
|
111
|
+
/** 总 PR 数 */
|
|
112
|
+
totalPrs: number;
|
|
113
|
+
/** 按用户统计(已排序) */
|
|
114
|
+
userStats: UserStats[];
|
|
115
|
+
}
|