diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 33764e1..4d29e87 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -305,6 +305,7 @@ modules: area: backend status: in_progress progress: 86 + note: "L0 数据层已完成;F1+F2(展示层)待做;阶段三待数据积累" position: [1750, 650] - id: cloud-products diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index f0fdb14..b5c6977 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -110,6 +110,21 @@ --- +### 📋 F1. Git 健康度后端——全量历史导入 + 跨项目聚合 +- status: todo +- complexity: M +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/lib.rs, src/lib/commands.ts +- acceptance: 新增 `ingest_full_git_history(project_id, project_path)` 命令(最多 500 条,INSERT OR IGNORE 幂等);新增 `get_project_commit_health(project_ids)` 命令返回各项目 conventional_rate/rework_rate/top_scopes;单元测试覆盖 ingest 幂等性和 health 聚合 + +### 📋 F2. 飞轮面板——Git 健康度区块 + 全量导入按钮 +- status: todo +- complexity: M +- depends: F1 +- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx +- acceptance: FlywheelPanel 底部新增"Git 执行质量"卡片,表格展示各项目 commit 规范化率/返工率/TOP scope;"全量导入"按钮对所有已接入项目触发 ingest_full_git_history,显示 loading 和结果摘要;无类型错误 + +--- + ### 阶段三:执行质量预测层 - status: todo - complexity: L diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs index b57129b..fd29ef9 100644 --- a/src-tauri/src/commands/commit_metrics.rs +++ b/src-tauri/src/commands/commit_metrics.rs @@ -154,6 +154,186 @@ pub fn get_commit_stats(project_id: String) -> Result { }) } +// ── 全量历史导入 ────────────────────────────────────────────────────────────── + +const FULL_HISTORY_LIMIT: usize = 500; + +/// 全量历史补录结果。 +#[derive(Debug, Serialize)] +pub struct IngestResult { + /// 本次新写入 commit 数 + pub imported: u32, + /// 已存在(INSERT OR IGNORE 跳过) + pub skipped: u32, +} + +/// 读取 git log 最近 limit 条:返回 (sha, subject, committed_at_iso)。 +fn read_git_log(root: &std::path::Path, limit: usize) -> Result, String> { + const FMT: &str = "--format=%H%x1f%s%x1f%cI"; + let out = crate::silent_cmd("git") + .args(["log", "-n", &limit.to_string(), FMT]) + .current_dir(root) + .output() + .map_err(|e| format!("git log 失败: {e}"))?; + if !out.status.success() { + return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr))); + } + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.split('\u{1f}'); + let sha = parts.next()?.trim().to_string(); + if sha.is_empty() { return None; } + let subject = parts.next().unwrap_or("").to_string(); + let date = parts.next().unwrap_or("").to_string(); + Some((sha, subject, date)) + }) + .collect()) +} + +/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。 +/// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE)。 +#[tauri::command] +pub fn ingest_full_git_history( + project_id: String, + project_path: String, +) -> Result { + let root = std::path::Path::new(project_path.trim_end_matches(['/', '\\'])); + if !root.exists() { + return Err(format!("目录不存在: {}", root.display())); + } + let commits = read_git_log(root, FULL_HISTORY_LIMIT)?; + let total = commits.len() as u32; + + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let mut imported = 0u32; + for (sha, subject, date) in &commits { + let p = parse_conventional(subject); + let n = conn.execute( + "INSERT OR IGNORE INTO commit_metrics + (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + sha, project_id, p.commit_type, p.scope, + p.is_rework as i32, p.is_ci_auto as i32, + if date.is_empty() { None } else { Some(date.as_str()) }, + ], + ).map_err(|e| e.to_string())?; + imported += n as u32; + } + tx.commit().map_err(|e| e.to_string())?; + Ok(IngestResult { imported, skipped: total - imported }) +} + +// ── 跨项目 Git 健康度聚合 ───────────────────────────────────────────────────── + +/// 单个 scope 的活跃度摘要。 +#[derive(Debug, Serialize)] +pub struct ScopeActivity { + pub scope: String, + pub commit_count: u32, + pub rework_count: u32, +} + +/// 单项目 Git 健康度指标。 +#[derive(Debug, Serialize)] +pub struct ProjectCommitHealth { + pub project_id: String, + pub total_commits: u32, + pub conventional_commits: u32, + /// conventional / total(0.0 – 1.0,三位小数) + pub conventional_rate: f64, + pub rework_commits: u32, + /// fix(非CI) / conventional(0.0 – 1.0,三位小数) + pub rework_rate: f64, + /// 按 commit 数降序的前 5 个 scope + pub top_scopes: Vec, +} + +/// 内部聚合逻辑,接受 Connection 引用,方便测试直接调用。 +pub fn project_commit_health_inner( + conn: &rusqlite::Connection, + project_ids: &[String], +) -> Result, String> { + let round3 = |v: f64| (v * 1000.0).round() / 1000.0; + let mut result = Vec::new(); + + for pid in project_ids { + let total: u32 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1", + rusqlite::params![pid], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) as u32; + if total == 0 { continue; } + + let conventional: u32 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND commit_type IS NOT NULL", + rusqlite::params![pid], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) as u32; + + let rework: u32 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND is_rework=1 AND is_ci_auto=0", + rusqlite::params![pid], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) as u32; + + let mut stmt = conn + .prepare( + "SELECT scope, + COUNT(*) AS cnt, + SUM(CASE WHEN is_rework=1 AND is_ci_auto=0 THEN 1 ELSE 0 END) AS rc + FROM commit_metrics + WHERE project_id = ?1 AND scope IS NOT NULL + GROUP BY scope + ORDER BY cnt DESC + LIMIT 5", + ) + .map_err(|e| e.to_string())?; + let top_scopes: Vec = stmt + .query_map(rusqlite::params![pid], |row| { + Ok(ScopeActivity { + scope: row.get(0)?, + commit_count: row.get::<_, i64>(1)? as u32, + rework_count: row.get::<_, i64>(2)? as u32, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + result.push(ProjectCommitHealth { + conventional_rate: round3(if total > 0 { conventional as f64 / total as f64 } else { 0.0 }), + rework_rate: round3(if conventional > 0 { rework as f64 / conventional as f64 } else { 0.0 }), + project_id: pid.clone(), + total_commits: total, + conventional_commits: conventional, + rework_commits: rework, + top_scopes, + }); + } + Ok(result) +} + +/// 批量查询各项目的 Git 健康度(直接从 commit_metrics 表聚合,不经 usage.json)。 +#[tauri::command] +pub fn get_project_commit_health( + project_ids: Vec, +) -> Result, String> { + if project_ids.is_empty() { + return Ok(vec![]); + } + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + project_commit_health_inner(&conn, &project_ids) +} + #[cfg(test)] mod tests { use super::*; @@ -249,4 +429,67 @@ mod tests { assert_eq!(total, 2); assert_eq!(conv, 1); } + + // ── F1 新增测试 ──────────────────────────────────────────────────────────── + + /// 辅助:向 in-memory DB 写入一批 commits + fn seed_commits(conn: &rusqlite::Connection, pid: &str, msgs: &[(&str, &str)]) { + for (sha, msg) in msgs { + record_commit(conn, pid, sha, msg, None).unwrap(); + } + } + + #[test] + fn health_empty_project_excluded() { + let conn = crate::db::conn_with_schema(); + // project_id 没有任何 commit,inner 函数应直接跳过返回空 + let result = project_commit_health_inner(&conn, &["ghost-proj".to_string()]).unwrap(); + assert!(result.is_empty(), "无数据项目应被跳过"); + } + + #[test] + fn health_aggregates_correctly() { + let conn = crate::db::conn_with_schema(); + seed_commits(&conn, "p1", &[ + ("a1", "feat(api): add endpoint"), + ("a2", "fix(api): correct response"), // rework + ("a3", "fix(ci): auto-fix [DeepSeek-V3]"), // CI,不计返工 + ("a4", "chore: update deps"), + ("a5", "random commit"), // 非 conventional + ]); + let result = project_commit_health_inner(&conn, &["p1".to_string()]).unwrap(); + let h = &result[0]; + assert_eq!(h.total_commits, 5); + assert_eq!(h.conventional_commits, 4); // a1/a2/a3/a4 + assert_eq!(h.rework_commits, 1); // 只有 a2(a3 是 CI) + assert!((h.conventional_rate - 0.8).abs() < 0.001); + assert!((h.rework_rate - 0.25).abs() < 0.001); // 1/4 conventional + } + + #[test] + fn health_top_scopes_ordered_by_count() { + let conn = crate::db::conn_with_schema(); + seed_commits(&conn, "p2", &[ + ("b1", "feat(api): x"), + ("b2", "feat(api): y"), + ("b3", "feat(api): z"), + ("b4", "fix(db): bug"), + ("b5", "chore(db): cleanup"), + ]); + let result = project_commit_health_inner(&conn, &["p2".to_string()]).unwrap(); + let h = &result[0]; + assert_eq!(h.top_scopes[0].scope, "api"); + assert_eq!(h.top_scopes[0].commit_count, 3); + assert_eq!(h.top_scopes[1].scope, "db"); + assert_eq!(h.top_scopes[1].commit_count, 2); + } + + #[test] + fn ingest_idempotent_via_record_commit() { + let conn = crate::db::conn_with_schema(); + record_commit(&conn, "p3", "sha-dup", "feat: first write", None).unwrap(); + record_commit(&conn, "p3", "sha-dup", "feat: second write", None).unwrap(); + let (total, _) = conventional_stats(&conn, "p3").unwrap(); + assert_eq!(total, 1, "重复 sha 应被 INSERT OR IGNORE 忽略"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 37259de..477effb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,8 @@ pub fn run() { commands::agent_infra::scan_doc_freshness, commands::agent_infra::get_doc_freshness_cache, commands::commit_metrics::get_commit_stats, + commands::commit_metrics::ingest_full_git_history, + commands::commit_metrics::get_project_commit_health, // mentor get_mentor_context, get_project_notes, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index c2b6144..7d29bd6 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1687,6 +1687,33 @@ export interface CommitStats { export const getCommitStats = (projectId: string) => invoke("get_commit_stats", { projectId }); +export interface IngestResult { + imported: number; + skipped: number; +} + +export const ingestFullGitHistory = (projectId: string, projectPath: string) => + invoke("ingest_full_git_history", { projectId, projectPath }); + +export interface ScopeActivity { + scope: string; + commit_count: number; + rework_count: number; +} + +export interface ProjectCommitHealth { + project_id: string; + total_commits: number; + conventional_commits: number; + conventional_rate: number; + rework_commits: number; + rework_rate: number; + top_scopes: ScopeActivity[]; +} + +export const getProjectCommitHealth = (projectIds: string[]) => + invoke("get_project_commit_health", { projectIds }); + // ── Beast Global Status (read-only) ─────────────────────────────────────────── export interface BeastGlobalStatus {