dev-manager-tauri/src-tauri/src/commands/commit_metrics.rs
lanrtop 9c2064e3d0 feat(flywheel-L0): F1 全量历史导入 + 跨项目 Git 健康度聚合
- ingest_full_git_history: 最多 500 条历史,INSERT OR IGNORE 幂等,事务批量写入
- get_project_commit_health: 直接聚合 commit_metrics,返回规范化率/返工率/top_scopes
- 前端 commands.ts 补 IngestResult/ProjectCommitHealth/ScopeActivity 封装
- 新增 4 个单元测试(空项目排除、聚合正确性、scope 排序、写入幂等)
2026-06-29 20:55:11 +09:00

496 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 typefeat/fix/chore...None 表示非 conventional commit。
pub commit_type: Option<String>,
/// scope如 estate1/cadNone 表示无 scope。
pub scope: Option<String>,
/// 是否返工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();
// 必须有 "<prefix>: <desc>" 结构
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 与可选 scopetype(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<u32, String> {
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<CommitStats, String> {
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<Vec<(String, String, String)>, 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<IngestResult, String> {
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 / total0.0 1.0,三位小数)
pub conventional_rate: f64,
pub rework_commits: u32,
/// fix(非CI) / conventional0.0 1.0,三位小数)
pub rework_rate: f64,
/// 按 commit 数降序的前 5 个 scope
pub top_scopes: Vec<ScopeActivity>,
}
/// 内部聚合逻辑,接受 Connection 引用,方便测试直接调用。
pub fn project_commit_health_inner(
conn: &rusqlite::Connection,
project_ids: &[String],
) -> Result<Vec<ProjectCommitHealth>, 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<ScopeActivity> = 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<String>,
) -> Result<Vec<ProjectCommitHealth>, 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 没有任何 commitinner 函数应直接跳过返回空
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); // 只有 a2a3 是 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 忽略");
}
}