Skip to content

Commit b557c7a

Browse files
✨ feat(weekly): 添加AI生成周报功能
- 新增周报生成命令和配置项 - 添加周报生成服务,支持Git和SVN提交记录采集 - 实现周报WebView界面,展示工作内容和工时统计 - 支持自动计算合理工时分配 - 新增周报相关类型定义和常量配置 🔧 chore(config): 更新package.json配置 - 添加生成周报命令配置 - 新增周报模板和自动保存选项 - 配置周报工时统计相关参数 🔧 chore(command): 扩展命令管理器 - 在命令管理器中注册周报生成命令 - 添加周报命令常量定义
1 parent 29e89ce commit b557c7a

8 files changed

+522
-1
lines changed

package.json

+41
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
"icon": "/images/icon.svg",
5656
"description": "选择用于生成提交信息的AI模型(OpenAI/Ollama/VS Code Provided)",
5757
"when": "(config.svn.enabled && svnOpenRepositoryCount > 0) || (config.git.enabled && gitOpenRepositoryCount > 0)"
58+
},
59+
{
60+
"command": "dish-ai-commit.generateWeeklyReport",
61+
"title": "Generate Weekly Report",
62+
"category": "[Dish AI Commit]",
63+
"icon": "/images/icon.svg",
64+
"description": "使用 AI 生成周报"
5865
}
5966
],
6067
"configuration": {
@@ -185,6 +192,36 @@
185192
"type": "boolean",
186193
"default": true,
187194
"description": "在提交信息中使用 emoji"
195+
},
196+
"dish-ai-commit.weeklyReport.template": {
197+
"type": "string",
198+
"default": "",
199+
"description": "周报模板"
200+
},
201+
"dish-ai-commit.weeklyReport.autoSave": {
202+
"type": "boolean",
203+
"default": true,
204+
"description": "自动保存周报"
205+
},
206+
"dish-ai-commit.weeklyReport": {
207+
"type": "object",
208+
"properties": {
209+
"totalHours": {
210+
"type": "number",
211+
"default": 40,
212+
"description": "每周工作总时长"
213+
},
214+
"totalDays": {
215+
"type": "number",
216+
"default": 7,
217+
"description": "统计天数"
218+
},
219+
"minUnit": {
220+
"type": "number",
221+
"default": 0.5,
222+
"description": "最小工时单位"
223+
}
224+
}
188225
}
189226
}
190227
},
@@ -219,6 +256,10 @@
219256
"command": "dish-ai-commit.selectModel",
220257
"group": "navigation",
221258
"when": "(config.svn.enabled && svnOpenRepositoryCount > 0) || (config.git.enabled && gitOpenRepositoryCount > 0)"
259+
},
260+
{
261+
"command": "dish-ai-commit.generateWeeklyReport",
262+
"when": ""
222263
}
223264
]
224265
}

src/commands.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from "vscode";
22
import { COMMANDS } from "./constants";
33
import { GenerateCommitCommand } from "./commands/GenerateCommitCommand";
44
import { SelectModelCommand } from "./commands/SelectModelCommand";
5+
import { GenerateWeeklyReportCommand } from "./commands/GenerateWeeklyReportCommand";
56
import { NotificationHandler } from "./utils/NotificationHandler";
67

78
export class CommandManager implements vscode.Disposable {
@@ -15,6 +16,7 @@ export class CommandManager implements vscode.Disposable {
1516
try {
1617
const generateCommand = new GenerateCommitCommand(this.context);
1718
const selectModelCommand = new SelectModelCommand(this.context);
19+
const weeklyReportCommand = new GenerateWeeklyReportCommand(this.context);
1820
console.log("COMMANDS.MODEL.SHOW", COMMANDS.MODEL.SHOW);
1921

2022
this.disposables.push(
@@ -41,7 +43,20 @@ export class CommandManager implements vscode.Disposable {
4143
error instanceof Error ? error.message : String(error)
4244
);
4345
}
44-
})
46+
}),
47+
vscode.commands.registerCommand(
48+
COMMANDS.WEEKLY_REPORT.GENERATE,
49+
async () => {
50+
try {
51+
await weeklyReportCommand.execute();
52+
} catch (error) {
53+
NotificationHandler.error(
54+
"command.weekly.report.failed",
55+
error instanceof Error ? error.message : String(error)
56+
);
57+
}
58+
}
59+
)
4560
);
4661
} catch (error) {
4762
NotificationHandler.error(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as vscode from "vscode";
2+
import { BaseCommand } from "./BaseCommand";
3+
import { NotificationHandler } from "../utils/NotificationHandler";
4+
import { ProgressHandler } from "../utils/ProgressHandler";
5+
import { LocalizationManager } from "../utils/LocalizationManager";
6+
import { WeeklyReportPanel } from "../webview/WeeklyReportPanel";
7+
import { SCMFactory } from "../scm/SCMProvider";
8+
9+
export class GenerateWeeklyReportCommand extends BaseCommand {
10+
async validateConfig(): Promise<boolean> {
11+
const scmProvider = await SCMFactory.detectSCM();
12+
if (!scmProvider) {
13+
const locManager = LocalizationManager.getInstance();
14+
await NotificationHandler.error(
15+
locManager.getMessage("scm.not.detected")
16+
);
17+
return false;
18+
}
19+
return true;
20+
}
21+
22+
async execute(): Promise<void> {
23+
try {
24+
if (!(await this.validateConfig())) {
25+
return;
26+
}
27+
28+
await ProgressHandler.withProgress(
29+
LocalizationManager.getInstance().getMessage("weeklyReport.generating"),
30+
async () => {
31+
WeeklyReportPanel.createOrShow(this.context.extensionUri);
32+
}
33+
);
34+
} catch (error) {
35+
if (error instanceof Error) {
36+
await NotificationHandler.error(
37+
LocalizationManager.getInstance().format(
38+
"weeklyReport.generation.failed",
39+
error.message
40+
)
41+
);
42+
}
43+
}
44+
}
45+
}

src/constants.ts

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export const COMMANDS = {
1111
MODEL: {
1212
SHOW: packageJson.contributes.commands[1].command,
1313
},
14+
WEEKLY_REPORT: {
15+
GENERATE: 'dish-ai-commit.generateWeeklyReport'
16+
}
1417
} as const;
1518

1619
// 添加类型导出

src/extension.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ConfigurationManager } from "./config/ConfigurationManager";
55
import { registerCommands } from "./commands";
66
import { LocalizationManager } from "./utils/LocalizationManager";
77
import { NotificationHandler } from "./utils/NotificationHandler";
8+
import { WeeklyReportPanel } from "./webview/WeeklyReportPanel";
89

910
// This method is called when your extension is activated
1011
// Your extension is activated the very first time the command is executed

src/services/weeklyReport.ts

+233
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import * as vscode from "vscode";
2+
import { exec } from "child_process";
3+
import { promisify } from "util";
4+
import * as fs from "fs";
5+
import * as path from "path";
6+
import * as os from "os";
7+
import { WorkItem, Repository } from "../types/weeklyReport";
8+
import { SCMFactory } from "../scm/SCMProvider";
9+
10+
const execAsync = promisify(exec);
11+
const readFileAsync = promisify(fs.readFile);
12+
const readdirAsync = promisify(fs.readdir);
13+
14+
export class WeeklyReportService {
15+
private readonly WORK_DAYS = 5; // 固定为5个工作日
16+
private readonly HOURS_PER_DAY = 8; // 每天8小时
17+
private allLogs: string[] = [];
18+
19+
constructor() {}
20+
21+
async generate(): Promise<WorkItem[]> {
22+
const scmProvider = await SCMFactory.detectSCM();
23+
if (!scmProvider) {
24+
throw new Error("No SCM provider detected");
25+
}
26+
27+
// 获取作者信息
28+
const author = await this.getAuthor(scmProvider.type);
29+
if (!author) {
30+
throw new Error("Unable to detect author information");
31+
}
32+
33+
const repositories = await this.findRepositories();
34+
await this.collectLogs(repositories, author);
35+
return this.processLogs();
36+
}
37+
38+
private async getSvnAuthor(): Promise<string | undefined> {
39+
try {
40+
const svnAuthPath = path.join(
41+
os.homedir(),
42+
".subversion",
43+
"auth",
44+
"svn.simple"
45+
);
46+
const files = await readdirAsync(svnAuthPath);
47+
48+
// 读取第一个认证文件
49+
if (files.length > 0) {
50+
const authFile = path.join(svnAuthPath, files[0]);
51+
const content = await readFileAsync(authFile, "utf-8");
52+
53+
// 使用正则表达式匹配用户名
54+
const usernameMatch = content.match(/username="([^"]+)"/);
55+
if (usernameMatch && usernameMatch[1]) {
56+
return usernameMatch[1];
57+
}
58+
}
59+
60+
// 如果无法从配置文件获取,尝试从 svn info 获取
61+
const { stdout } = await execAsync("svn info --show-item author");
62+
return stdout.trim();
63+
} catch (error) {
64+
console.error(`Error getting SVN author: ${error}`);
65+
return undefined;
66+
}
67+
}
68+
69+
private async getAuthor(type: "git" | "svn"): Promise<string | undefined> {
70+
try {
71+
if (type === "git") {
72+
const { stdout } = await execAsync("git config user.name");
73+
return stdout.trim();
74+
} else {
75+
return await this.getSvnAuthor();
76+
}
77+
} catch (error) {
78+
console.error(`Error getting author: ${error}`);
79+
return undefined;
80+
}
81+
}
82+
83+
private async collectLogs(repositories: Repository[], author: string) {
84+
for (const repo of repositories) {
85+
if (repo.type === "git") {
86+
await this.collectGitLogs(repo.path, author);
87+
} else {
88+
await this.collectSvnLogs(repo.path, author);
89+
}
90+
}
91+
}
92+
93+
private getLastWeekDates(): { start: Date; end: Date } {
94+
const today = new Date();
95+
const currentDay = today.getDay();
96+
97+
// 计算上周一的日期
98+
const lastMonday = new Date(today);
99+
lastMonday.setDate(today.getDate() - currentDay - 7 + 1);
100+
lastMonday.setHours(0, 0, 0, 0);
101+
102+
// 计算上周五的日期
103+
const lastFriday = new Date(lastMonday);
104+
lastFriday.setDate(lastMonday.getDate() + 4);
105+
lastFriday.setHours(23, 59, 59, 999);
106+
107+
return { start: lastMonday, end: lastFriday };
108+
}
109+
110+
private async collectGitLogs(repoPath: string, author: string) {
111+
const { start, end } = this.getLastWeekDates();
112+
const startDate = start.toISOString();
113+
const endDate = end.toISOString();
114+
115+
const command = `git log --after="${startDate}" --before="${endDate}" --author="${author}" --pretty=format:"%s"`;
116+
try {
117+
const { stdout } = await execAsync(command, { cwd: repoPath });
118+
if (stdout.trim()) {
119+
this.allLogs = this.allLogs.concat(stdout.trim().split("\n"));
120+
}
121+
} catch (error) {
122+
console.error(`Error collecting Git logs: ${error}`);
123+
}
124+
}
125+
126+
private async findRepositories(): Promise<Repository[]> {
127+
const repositories: Repository[] = [];
128+
const workspaceFolders = vscode.workspace.workspaceFolders;
129+
130+
if (!workspaceFolders) {
131+
return repositories;
132+
}
133+
134+
for (const folder of workspaceFolders) {
135+
try {
136+
// 检查是否是 Git 仓库
137+
const { stdout: gitOutput } = await execAsync(
138+
"git rev-parse --git-dir",
139+
{
140+
cwd: folder.uri.fsPath,
141+
}
142+
);
143+
if (gitOutput) {
144+
repositories.push({
145+
type: "git",
146+
path: folder.uri.fsPath,
147+
});
148+
continue;
149+
}
150+
} catch {}
151+
152+
try {
153+
// 检查是否是 SVN 仓库
154+
const { stdout: svnOutput } = await execAsync("svn info", {
155+
cwd: folder.uri.fsPath,
156+
});
157+
if (svnOutput) {
158+
repositories.push({
159+
type: "svn",
160+
path: folder.uri.fsPath,
161+
});
162+
}
163+
} catch {}
164+
}
165+
166+
return repositories;
167+
}
168+
169+
private async collectSvnLogs(repoPath: string, author: string) {
170+
const { start, end } = this.getLastWeekDates();
171+
try {
172+
const command = `svn log -r {${start.toISOString()}}:{${end.toISOString()}} --search="${author}" --xml`;
173+
const { stdout } = await execAsync(command, { cwd: repoPath });
174+
const matches = stdout.matchAll(/<msg>([\s\S]*?)<\/msg>/g);
175+
for (const match of matches) {
176+
if (match[1] && match[1].trim()) {
177+
this.allLogs.push(match[1].trim());
178+
}
179+
}
180+
} catch (error) {
181+
console.error(`Error collecting SVN logs: ${error}`);
182+
}
183+
}
184+
185+
private processLogs(): WorkItem[] {
186+
const uniqueLogs = [...new Set(this.allLogs)];
187+
const workItems: WorkItem[] = [];
188+
const totalHours = this.WORK_DAYS * this.HOURS_PER_DAY;
189+
const hoursPerLog = totalHours / uniqueLogs.length;
190+
191+
uniqueLogs.forEach((log, index) => {
192+
let timeSpent = hoursPerLog;
193+
if (index === uniqueLogs.length - 1) {
194+
const totalAllocated = workItems.reduce(
195+
(sum, item) => sum + parseFloat(item.time),
196+
0
197+
);
198+
const remaining = totalHours - totalAllocated;
199+
if (remaining > 0) {
200+
timeSpent = remaining;
201+
}
202+
}
203+
204+
workItems.push({
205+
content: log,
206+
time: `${timeSpent.toFixed(1)}h`,
207+
description: this.generateDescription(log),
208+
});
209+
});
210+
211+
return workItems;
212+
}
213+
214+
private generateDescription(log: string): string {
215+
// 移除常见的提交前缀,如 feat:, fix: 等
216+
const cleanLog = log.replace(
217+
/^(feat|fix|docs|style|refactor|test|chore|perf):\s*/i,
218+
""
219+
);
220+
221+
// 移除 emoji
222+
const noEmoji = cleanLog
223+
.replace(/:[a-z_]+:|[\u{1F300}-\u{1F6FF}]/gu, "")
224+
.trim();
225+
226+
// 如果内容过短,添加更多描述
227+
if (noEmoji.length < 20) {
228+
return `完成${noEmoji}相关功能的开发和调试工作`;
229+
}
230+
231+
return noEmoji;
232+
}
233+
}

0 commit comments

Comments
 (0)