//! Conventional Commit 解析与执行质量指标(飞轮 L0 数据地基)。 //! //! 从 git commit message 解析出 type/scope/返工/CI 自修信号,写入 commit_metrics 表, //! 取代枯竭的「复盘笔记解析」路径。纯解析逻辑(`parse_conventional`)与 DB 无关,可独立单测。 use rusqlite::Connection; use serde::{Deserialize, Serialize}; /// 一条 commit 的解析结果。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedCommit { /// conventional type(feat/fix/chore...);None 表示非 conventional commit。 pub commit_type: Option, /// scope(如 estate1/cad);None 表示无 scope。 pub scope: Option, /// 是否返工:type == fix。 pub is_rework: bool, /// 是否 CI 自动修复(含 [DeepSeek-V3]/[R1] 等标记),聚合返工率时剔除。 pub is_ci_auto: bool, } /// CI 自动修复 commit 的标记(来自 enterprise-system CI:`fix(ci): auto-fix ... [DeepSeek-V3]`)。 const CI_AUTO_MARKERS: [&str; 3] = ["[DeepSeek", "[R1]", "[V3]"]; /// 解析一条 commit message 为 Conventional Commit 结构。 /// /// 纯函数,不触碰 DB。无法识别为 conventional 的(merge commit、随手写的) /// `commit_type` 记为 None,聚合时单列、不污染 fix 率。 pub fn parse_conventional(message: &str) -> ParsedCommit { let is_ci_auto = CI_AUTO_MARKERS.iter().any(|m| message.contains(m)); // 只看首行(subject) let subject = message.lines().next().unwrap_or("").trim(); // 必须有 ": " 结构 let prefix = match subject.split_once(':') { Some((p, _desc)) => p.trim(), None => return non_conventional(is_ci_auto), }; // 去掉 breaking change 的末尾 '!' let prefix = prefix.trim_end_matches('!'); // 拆出 type 与可选 scope:type(scope) let (typ, scope) = match prefix.split_once('(') { Some((t, rest)) => { let scope = rest .strip_suffix(')') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); (t.trim(), scope) } None => (prefix, None), }; // type 必须是纯小写字母(feat/fix/...),否则视为非 conventional(如 "Merge pull request") if typ.is_empty() || !typ.chars().all(|c| c.is_ascii_lowercase()) { return non_conventional(is_ci_auto); } ParsedCommit { is_rework: typ == "fix", commit_type: Some(typ.to_string()), scope, is_ci_auto, } } fn non_conventional(is_ci_auto: bool) -> ParsedCommit { ParsedCommit { commit_type: None, scope: None, is_rework: false, is_ci_auto, } } /// 解析并写入一条 commit 记录(sha 为主键,重复写入忽略)。 pub fn record_commit( conn: &Connection, project_id: &str, sha: &str, message: &str, committed_at: Option<&str>, ) -> Result<(), String> { let p = parse_conventional(message); 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, committed_at, ], ) .map_err(|e| e.to_string())?; Ok(()) } /// 项目的有效返工次数:type=fix 且非 CI 自动修复。 pub fn rework_count(conn: &Connection, project_id: &str) -> Result { conn.query_row( "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND is_rework = 1 AND is_ci_auto = 0", rusqlite::params![project_id], |r| r.get::<_, i64>(0), ) .map(|n| n as u32) .map_err(|e| e.to_string()) } /// commit 规范化统计:(总数, conventional 数)。供数据可信度提示(卡 D)使用。 pub fn conventional_stats(conn: &Connection, project_id: &str) -> Result<(u32, u32), String> { let total: i64 = conn .query_row( "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1", rusqlite::params![project_id], |r| r.get(0), ) .map_err(|e| e.to_string())?; let conv: i64 = conn .query_row( "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND commit_type IS NOT NULL", rusqlite::params![project_id], |r| r.get(0), ) .map_err(|e| e.to_string())?; Ok((total as u32, conv as u32)) } /// 项目 commit 执行质量统计(供前端数据可信度展示)。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitStats { pub total: u32, pub conventional: u32, pub rework: u32, } /// 查询某项目的 commit 执行质量统计(总数 / conventional 数 / 有效返工数)。 #[tauri::command] pub fn get_commit_stats(project_id: String) -> Result { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (total, conventional) = conventional_stats(&conn, &project_id)?; let rework = rework_count(&conn, &project_id)?; Ok(CommitStats { total, conventional, rework, }) } // ── 全量历史导入 ────────────────────────────────────────────────────────────── 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::*; #[test] fn parses_feat_with_scope() { let p = parse_conventional("feat(hub/rpc): add shared type system"); assert_eq!(p.commit_type.as_deref(), Some("feat")); assert_eq!(p.scope.as_deref(), Some("hub/rpc")); assert!(!p.is_rework); assert!(!p.is_ci_auto); } #[test] fn parses_fix_as_rework() { let p = parse_conventional("fix(estate1/view): correct sort order"); assert_eq!(p.commit_type.as_deref(), Some("fix")); assert_eq!(p.scope.as_deref(), Some("estate1/view")); assert!(p.is_rework); assert!(!p.is_ci_auto); } #[test] fn parses_type_without_scope() { let p = parse_conventional("chore: update AGENTS.md"); assert_eq!(p.commit_type.as_deref(), Some("chore")); assert_eq!(p.scope, None); assert!(!p.is_rework); } #[test] fn detects_ci_auto_fix() { let p = parse_conventional("fix(ci): auto-fix type/lint errors [DeepSeek-V3]"); assert_eq!(p.commit_type.as_deref(), Some("fix")); assert!(p.is_rework); assert!(p.is_ci_auto, "应识别 [DeepSeek-V3] 为 CI 自修"); } #[test] fn breaking_change_marker_stripped() { let p = parse_conventional("feat(api)!: drop legacy endpoint"); assert_eq!(p.commit_type.as_deref(), Some("feat")); assert_eq!(p.scope.as_deref(), Some("api")); } #[test] fn merge_commit_is_non_conventional() { let p = parse_conventional("Merge pull request 'xxx' (#1) from dev into main"); assert_eq!(p.commit_type, None); assert!(!p.is_rework); } #[test] fn freeform_message_is_non_conventional() { let p = parse_conventional("update stuff"); assert_eq!(p.commit_type, None); assert_eq!(p.scope, None); } #[test] fn uses_first_line_only() { let p = parse_conventional("feat(x): title\n\nbody line with: colon"); assert_eq!(p.commit_type.as_deref(), Some("feat")); assert_eq!(p.scope.as_deref(), Some("x")); } #[test] fn rework_count_excludes_ci_auto_and_non_fix() { let conn = crate::db::conn_with_schema(); record_commit(&conn, "proj", "sha1", "fix(a): real bug", None).unwrap(); record_commit(&conn, "proj", "sha2", "fix(ci): auto-fix [DeepSeek-V3]", None).unwrap(); record_commit(&conn, "proj", "sha3", "feat(a): feature", None).unwrap(); record_commit(&conn, "proj", "sha4", "update stuff", None).unwrap(); // 仅 sha1 算有效返工(sha2 是 CI 自修、sha3 是 feat、sha4 非规范) assert_eq!(rework_count(&conn, "proj").unwrap(), 1); } #[test] fn record_commit_is_idempotent() { let conn = crate::db::conn_with_schema(); record_commit(&conn, "proj", "dup", "fix: x", None).unwrap(); record_commit(&conn, "proj", "dup", "fix: x", None).unwrap(); let (total, _) = conventional_stats(&conn, "proj").unwrap(); assert_eq!(total, 1, "同 sha 重复写入应被忽略"); } #[test] fn conventional_stats_counts_ratio() { let conn = crate::db::conn_with_schema(); record_commit(&conn, "proj", "s1", "feat: a", None).unwrap(); record_commit(&conn, "proj", "s2", "random text", None).unwrap(); let (total, conv) = conventional_stats(&conn, "proj").unwrap(); 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 忽略"); } }