Skip to content

Commit

Permalink
✨ feat(weekly): 添加AI生成周报功能
Browse files Browse the repository at this point in the history
- 新增周报生成命令和配置项
- 添加周报生成服务,支持Git和SVN提交记录采集
- 实现周报WebView界面,展示工作内容和工时统计
- 支持自动计算合理工时分配
- 新增周报相关类型定义和常量配置

🔧 chore(config): 更新package.json配置

- 添加生成周报命令配置
- 新增周报模板和自动保存选项
- 配置周报工时统计相关参数

🔧 chore(command): 扩展命令管理器

- 在命令管理器中注册周报生成命令
- 添加周报命令常量定义
  • Loading branch information
littleCareless committed Dec 12, 2024
1 parent 29e89ce commit b557c7a
Show file tree
Hide file tree
Showing 8 changed files with 522 additions and 1 deletion.
41 changes: 41 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"icon": "/images/icon.svg",
"description": "选择用于生成提交信息的AI模型(OpenAI/Ollama/VS Code Provided)",
"when": "(config.svn.enabled && svnOpenRepositoryCount > 0) || (config.git.enabled && gitOpenRepositoryCount > 0)"
},
{
"command": "dish-ai-commit.generateWeeklyReport",
"title": "Generate Weekly Report",
"category": "[Dish AI Commit]",
"icon": "/images/icon.svg",
"description": "使用 AI 生成周报"
}
],
"configuration": {
Expand Down Expand Up @@ -185,6 +192,36 @@
"type": "boolean",
"default": true,
"description": "在提交信息中使用 emoji"
},
"dish-ai-commit.weeklyReport.template": {
"type": "string",
"default": "",
"description": "周报模板"
},
"dish-ai-commit.weeklyReport.autoSave": {
"type": "boolean",
"default": true,
"description": "自动保存周报"
},
"dish-ai-commit.weeklyReport": {
"type": "object",
"properties": {
"totalHours": {
"type": "number",
"default": 40,
"description": "每周工作总时长"
},
"totalDays": {
"type": "number",
"default": 7,
"description": "统计天数"
},
"minUnit": {
"type": "number",
"default": 0.5,
"description": "最小工时单位"
}
}
}
}
},
Expand Down Expand Up @@ -219,6 +256,10 @@
"command": "dish-ai-commit.selectModel",
"group": "navigation",
"when": "(config.svn.enabled && svnOpenRepositoryCount > 0) || (config.git.enabled && gitOpenRepositoryCount > 0)"
},
{
"command": "dish-ai-commit.generateWeeklyReport",
"when": ""
}
]
}
Expand Down
17 changes: 16 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from "vscode";
import { COMMANDS } from "./constants";
import { GenerateCommitCommand } from "./commands/GenerateCommitCommand";
import { SelectModelCommand } from "./commands/SelectModelCommand";
import { GenerateWeeklyReportCommand } from "./commands/GenerateWeeklyReportCommand";
import { NotificationHandler } from "./utils/NotificationHandler";

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

this.disposables.push(
Expand All @@ -41,7 +43,20 @@ export class CommandManager implements vscode.Disposable {
error instanceof Error ? error.message : String(error)
);
}
})
}),
vscode.commands.registerCommand(
COMMANDS.WEEKLY_REPORT.GENERATE,
async () => {
try {
await weeklyReportCommand.execute();
} catch (error) {
NotificationHandler.error(
"command.weekly.report.failed",
error instanceof Error ? error.message : String(error)
);
}
}
)
);
} catch (error) {
NotificationHandler.error(
Expand Down
45 changes: 45 additions & 0 deletions src/commands/GenerateWeeklyReportCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as vscode from "vscode";
import { BaseCommand } from "./BaseCommand";
import { NotificationHandler } from "../utils/NotificationHandler";
import { ProgressHandler } from "../utils/ProgressHandler";
import { LocalizationManager } from "../utils/LocalizationManager";
import { WeeklyReportPanel } from "../webview/WeeklyReportPanel";
import { SCMFactory } from "../scm/SCMProvider";

export class GenerateWeeklyReportCommand extends BaseCommand {
async validateConfig(): Promise<boolean> {
const scmProvider = await SCMFactory.detectSCM();
if (!scmProvider) {
const locManager = LocalizationManager.getInstance();
await NotificationHandler.error(
locManager.getMessage("scm.not.detected")
);
return false;
}
return true;
}

async execute(): Promise<void> {
try {
if (!(await this.validateConfig())) {
return;
}

await ProgressHandler.withProgress(
LocalizationManager.getInstance().getMessage("weeklyReport.generating"),
async () => {
WeeklyReportPanel.createOrShow(this.context.extensionUri);
}
);
} catch (error) {
if (error instanceof Error) {
await NotificationHandler.error(
LocalizationManager.getInstance().format(
"weeklyReport.generation.failed",
error.message
)
);
}
}
}
}
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const COMMANDS = {
MODEL: {
SHOW: packageJson.contributes.commands[1].command,
},
WEEKLY_REPORT: {
GENERATE: 'dish-ai-commit.generateWeeklyReport'
}
} as const;

// 添加类型导出
Expand Down
1 change: 1 addition & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ConfigurationManager } from "./config/ConfigurationManager";
import { registerCommands } from "./commands";
import { LocalizationManager } from "./utils/LocalizationManager";
import { NotificationHandler } from "./utils/NotificationHandler";
import { WeeklyReportPanel } from "./webview/WeeklyReportPanel";

// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
Expand Down
233 changes: 233 additions & 0 deletions src/services/weeklyReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import * as vscode from "vscode";
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { WorkItem, Repository } from "../types/weeklyReport";
import { SCMFactory } from "../scm/SCMProvider";

const execAsync = promisify(exec);
const readFileAsync = promisify(fs.readFile);
const readdirAsync = promisify(fs.readdir);

export class WeeklyReportService {
private readonly WORK_DAYS = 5; // 固定为5个工作日
private readonly HOURS_PER_DAY = 8; // 每天8小时
private allLogs: string[] = [];

constructor() {}

async generate(): Promise<WorkItem[]> {
const scmProvider = await SCMFactory.detectSCM();
if (!scmProvider) {
throw new Error("No SCM provider detected");
}

// 获取作者信息
const author = await this.getAuthor(scmProvider.type);
if (!author) {
throw new Error("Unable to detect author information");
}

const repositories = await this.findRepositories();
await this.collectLogs(repositories, author);
return this.processLogs();
}

private async getSvnAuthor(): Promise<string | undefined> {
try {
const svnAuthPath = path.join(
os.homedir(),
".subversion",
"auth",
"svn.simple"
);
const files = await readdirAsync(svnAuthPath);

// 读取第一个认证文件
if (files.length > 0) {
const authFile = path.join(svnAuthPath, files[0]);
const content = await readFileAsync(authFile, "utf-8");

// 使用正则表达式匹配用户名
const usernameMatch = content.match(/username="([^"]+)"/);
if (usernameMatch && usernameMatch[1]) {
return usernameMatch[1];
}
}

// 如果无法从配置文件获取,尝试从 svn info 获取
const { stdout } = await execAsync("svn info --show-item author");
return stdout.trim();
} catch (error) {
console.error(`Error getting SVN author: ${error}`);
return undefined;
}
}

private async getAuthor(type: "git" | "svn"): Promise<string | undefined> {
try {
if (type === "git") {
const { stdout } = await execAsync("git config user.name");
return stdout.trim();
} else {
return await this.getSvnAuthor();
}
} catch (error) {
console.error(`Error getting author: ${error}`);
return undefined;
}
}

private async collectLogs(repositories: Repository[], author: string) {
for (const repo of repositories) {
if (repo.type === "git") {
await this.collectGitLogs(repo.path, author);
} else {
await this.collectSvnLogs(repo.path, author);
}
}
}

private getLastWeekDates(): { start: Date; end: Date } {
const today = new Date();
const currentDay = today.getDay();

// 计算上周一的日期
const lastMonday = new Date(today);
lastMonday.setDate(today.getDate() - currentDay - 7 + 1);
lastMonday.setHours(0, 0, 0, 0);

// 计算上周五的日期
const lastFriday = new Date(lastMonday);
lastFriday.setDate(lastMonday.getDate() + 4);
lastFriday.setHours(23, 59, 59, 999);

return { start: lastMonday, end: lastFriday };
}

private async collectGitLogs(repoPath: string, author: string) {
const { start, end } = this.getLastWeekDates();
const startDate = start.toISOString();
const endDate = end.toISOString();

const command = `git log --after="${startDate}" --before="${endDate}" --author="${author}" --pretty=format:"%s"`;
try {
const { stdout } = await execAsync(command, { cwd: repoPath });
if (stdout.trim()) {
this.allLogs = this.allLogs.concat(stdout.trim().split("\n"));
}
} catch (error) {
console.error(`Error collecting Git logs: ${error}`);
}
}

private async findRepositories(): Promise<Repository[]> {
const repositories: Repository[] = [];
const workspaceFolders = vscode.workspace.workspaceFolders;

if (!workspaceFolders) {
return repositories;
}

for (const folder of workspaceFolders) {
try {
// 检查是否是 Git 仓库
const { stdout: gitOutput } = await execAsync(
"git rev-parse --git-dir",
{
cwd: folder.uri.fsPath,
}
);
if (gitOutput) {
repositories.push({
type: "git",
path: folder.uri.fsPath,
});
continue;
}
} catch {}

try {
// 检查是否是 SVN 仓库
const { stdout: svnOutput } = await execAsync("svn info", {
cwd: folder.uri.fsPath,
});
if (svnOutput) {
repositories.push({
type: "svn",
path: folder.uri.fsPath,
});
}
} catch {}
}

return repositories;
}

private async collectSvnLogs(repoPath: string, author: string) {
const { start, end } = this.getLastWeekDates();
try {
const command = `svn log -r {${start.toISOString()}}:{${end.toISOString()}} --search="${author}" --xml`;
const { stdout } = await execAsync(command, { cwd: repoPath });
const matches = stdout.matchAll(/<msg>([\s\S]*?)<\/msg>/g);
for (const match of matches) {
if (match[1] && match[1].trim()) {
this.allLogs.push(match[1].trim());
}
}
} catch (error) {
console.error(`Error collecting SVN logs: ${error}`);
}
}

private processLogs(): WorkItem[] {
const uniqueLogs = [...new Set(this.allLogs)];
const workItems: WorkItem[] = [];
const totalHours = this.WORK_DAYS * this.HOURS_PER_DAY;
const hoursPerLog = totalHours / uniqueLogs.length;

uniqueLogs.forEach((log, index) => {
let timeSpent = hoursPerLog;
if (index === uniqueLogs.length - 1) {
const totalAllocated = workItems.reduce(
(sum, item) => sum + parseFloat(item.time),
0
);
const remaining = totalHours - totalAllocated;
if (remaining > 0) {
timeSpent = remaining;
}
}

workItems.push({
content: log,
time: `${timeSpent.toFixed(1)}h`,
description: this.generateDescription(log),
});
});

return workItems;
}

private generateDescription(log: string): string {
// 移除常见的提交前缀,如 feat:, fix: 等
const cleanLog = log.replace(
/^(feat|fix|docs|style|refactor|test|chore|perf):\s*/i,
""
);

// 移除 emoji
const noEmoji = cleanLog
.replace(/:[a-z_]+:|[\u{1F300}-\u{1F6FF}]/gu, "")
.trim();

// 如果内容过短,添加更多描述
if (noEmoji.length < 20) {
return `完成${noEmoji}相关功能的开发和调试工作`;
}

return noEmoji;
}
}
Loading

0 comments on commit b557c7a

Please sign in to comment.