5e32375f0e
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
277 lines
7.4 KiB
TypeScript
277 lines
7.4 KiB
TypeScript
/**
|
|
* Commit Message 生成器
|
|
*
|
|
* 参考 aider 的 message_generator 实现
|
|
* 支持 conventional、simple、detailed 三种格式
|
|
*/
|
|
|
|
import * as path from 'path';
|
|
import type { DiffResult, MessageFormatConfig, FileDiff } from './types.js';
|
|
|
|
export class MessageGenerator {
|
|
private config: MessageFormatConfig;
|
|
|
|
constructor(config: MessageFormatConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* 生成 commit message
|
|
*/
|
|
generate(diff: DiffResult, files?: string[]): string {
|
|
const fileList = files || diff.files.map((f) => f.path);
|
|
|
|
switch (this.config.style) {
|
|
case 'conventional':
|
|
return this.generateConventional(diff, fileList);
|
|
case 'simple':
|
|
return this.generateSimple(diff, fileList);
|
|
case 'detailed':
|
|
return this.generateDetailed(diff, fileList);
|
|
default:
|
|
return this.generateSimple(diff, fileList);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Conventional Commits 格式
|
|
* type(scope): subject
|
|
*/
|
|
private generateConventional(diff: DiffResult, files: string[]): string {
|
|
const type = this.detectChangeType(diff, files);
|
|
const scope = this.detectScope(files);
|
|
const subject = this.generateSubject(diff, files);
|
|
|
|
let message = type;
|
|
if (scope) {
|
|
message += `(${scope})`;
|
|
}
|
|
message += `: ${subject}`;
|
|
|
|
// 截断到最大长度
|
|
if (message.length > this.config.maxLength) {
|
|
message = message.slice(0, this.config.maxLength - 3) + '...';
|
|
}
|
|
|
|
// 添加文件列表
|
|
if (this.config.includeFileList && files.length <= 5) {
|
|
const fileListStr = files.map((f) => `- ${f}`).join('\n');
|
|
message += `\n\nFiles:\n${fileListStr}`;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* 简单格式
|
|
* action file(s)
|
|
*/
|
|
private generateSimple(diff: DiffResult, files: string[]): string {
|
|
const action = this.detectAction(diff, files);
|
|
|
|
if (files.length === 1) {
|
|
return `${action} ${path.basename(files[0])}`;
|
|
} else if (files.length <= 3) {
|
|
const fileNames = files.map((f) => path.basename(f));
|
|
return `${action} ${fileNames.join(', ')}`;
|
|
} else {
|
|
return `${action} ${files.length} files`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 详细格式
|
|
* type(scope): subject
|
|
*
|
|
* body with file list
|
|
*
|
|
* footer with stats
|
|
*/
|
|
private generateDetailed(diff: DiffResult, files: string[]): string {
|
|
const header = this.generateConventional(diff, files).split('\n')[0];
|
|
const body = this.generateBody(diff, files);
|
|
const footer = this.generateFooter(diff);
|
|
|
|
return [header, '', body, '', footer].filter(Boolean).join('\n');
|
|
}
|
|
|
|
/**
|
|
* 检测变更类型
|
|
*/
|
|
private detectChangeType(diff: DiffResult, files: string[]): string {
|
|
const hasNewFiles = diff.files.some((f) => f.status === 'added');
|
|
const hasDeletedFiles = diff.files.some((f) => f.status === 'deleted');
|
|
|
|
// 检查特殊文件类型
|
|
const hasTestFiles = files.some((f) =>
|
|
f.includes('test') || f.includes('spec') || f.includes('.test.')
|
|
);
|
|
const hasDocFiles = files.some((f) =>
|
|
f.match(/\.(md|txt|rst|doc)$/i)
|
|
);
|
|
const hasConfigFiles = files.some((f) =>
|
|
f.match(/(config|\.json|\.yaml|\.yml|\.toml|\.ini)$/i) ||
|
|
f.includes('package.json') ||
|
|
f.includes('tsconfig')
|
|
);
|
|
const hasStyleFiles = files.some((f) =>
|
|
f.match(/\.(css|scss|less|sass|styl)$/i)
|
|
);
|
|
|
|
// 根据文件类型判断
|
|
if (hasTestFiles && files.every((f) => f.includes('test') || f.includes('spec'))) {
|
|
return 'test';
|
|
}
|
|
if (hasDocFiles && files.every((f) => f.match(/\.(md|txt|rst|doc)$/i))) {
|
|
return 'docs';
|
|
}
|
|
if (hasConfigFiles && files.every((f) =>
|
|
f.match(/(config|\.json|\.yaml|\.yml|\.toml|\.ini)$/i) ||
|
|
f.includes('package.json')
|
|
)) {
|
|
return 'chore';
|
|
}
|
|
if (hasStyleFiles && files.every((f) => f.match(/\.(css|scss|less|sass|styl)$/i))) {
|
|
return 'style';
|
|
}
|
|
|
|
// 根据变更类型判断
|
|
if (hasNewFiles && !hasDeletedFiles) {
|
|
return 'feat';
|
|
}
|
|
if (hasDeletedFiles && !hasNewFiles) {
|
|
return 'refactor';
|
|
}
|
|
|
|
// 分析内容判断是 fix 还是 feat
|
|
const content = diff.files
|
|
.map((f) => f.hunks.map((h) => h.content).join(''))
|
|
.join('');
|
|
|
|
if (
|
|
content.toLowerCase().includes('fix') ||
|
|
content.toLowerCase().includes('bug') ||
|
|
content.toLowerCase().includes('error') ||
|
|
content.toLowerCase().includes('issue')
|
|
) {
|
|
return 'fix';
|
|
}
|
|
|
|
// 默认为 feat
|
|
return 'feat';
|
|
}
|
|
|
|
/**
|
|
* 检测范围 (scope)
|
|
*/
|
|
private detectScope(files: string[]): string | null {
|
|
if (files.length === 0) return null;
|
|
|
|
// 找共同目录
|
|
const dirs = files.map((f) => {
|
|
const parts = f.split('/');
|
|
// 排除 src 目录,取第一个有意义的目录
|
|
return parts.filter((p) => p !== 'src' && p !== '.' && !p.includes('.'))[0];
|
|
});
|
|
|
|
// 如果所有文件在同一目录下
|
|
const uniqueDirs = [...new Set(dirs.filter(Boolean))];
|
|
if (uniqueDirs.length === 1) {
|
|
return uniqueDirs[0];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 生成主题行
|
|
*/
|
|
private generateSubject(diff: DiffResult, files: string[]): string {
|
|
const action = this.detectAction(diff, files);
|
|
|
|
if (files.length === 1) {
|
|
const fileName = path.basename(files[0]);
|
|
const ext = path.extname(fileName);
|
|
const name = path.basename(fileName, ext);
|
|
return `${action} ${name}`;
|
|
}
|
|
|
|
// 找共同特征
|
|
const extensions = [...new Set(files.map((f) => path.extname(f)))];
|
|
if (extensions.length === 1 && extensions[0]) {
|
|
return `${action} ${files.length} ${extensions[0].slice(1)} files`;
|
|
}
|
|
|
|
const dirs = [...new Set(files.map((f) => f.split('/')[0]))];
|
|
if (dirs.length === 1 && dirs[0] !== '.') {
|
|
return `${action} ${dirs[0]} module`;
|
|
}
|
|
|
|
return `${action} ${files.length} files`;
|
|
}
|
|
|
|
/**
|
|
* 检测操作动词
|
|
*/
|
|
private detectAction(diff: DiffResult, files: string[]): string {
|
|
const hasAdded = diff.files.some((f) => f.status === 'added');
|
|
const hasDeleted = diff.files.some((f) => f.status === 'deleted');
|
|
const hasModified = diff.files.some((f) => f.status === 'modified');
|
|
const hasRenamed = diff.files.some((f) => f.status === 'renamed');
|
|
|
|
if (hasAdded && !hasModified && !hasDeleted) return 'add';
|
|
if (hasDeleted && !hasModified && !hasAdded) return 'remove';
|
|
if (hasRenamed) return 'rename';
|
|
if (hasModified && !hasAdded && !hasDeleted) return 'update';
|
|
|
|
return 'update';
|
|
}
|
|
|
|
/**
|
|
* 生成正文
|
|
*/
|
|
private generateBody(diff: DiffResult, files: string[]): string {
|
|
const changes: string[] = [];
|
|
|
|
for (const file of files) {
|
|
const fileDiff = diff.files.find((f) => f.path === file);
|
|
const action = fileDiff
|
|
? this.getActionWord(fileDiff.status)
|
|
: 'Modified';
|
|
changes.push(`- ${action}: ${file}`);
|
|
}
|
|
|
|
return changes.join('\n');
|
|
}
|
|
|
|
/**
|
|
* 获取操作词
|
|
*/
|
|
private getActionWord(status: string): string {
|
|
switch (status) {
|
|
case 'added':
|
|
return 'Added';
|
|
case 'deleted':
|
|
return 'Removed';
|
|
case 'renamed':
|
|
return 'Renamed';
|
|
case 'copied':
|
|
return 'Copied';
|
|
case 'modified':
|
|
default:
|
|
return 'Modified';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 生成页脚
|
|
*/
|
|
private generateFooter(diff: DiffResult): string {
|
|
const { stats } = diff;
|
|
if (stats.filesChanged === 0) {
|
|
return '';
|
|
}
|
|
return `Stats: ${stats.filesChanged} file(s), +${stats.insertions}/-${stats.deletions}`;
|
|
}
|
|
}
|