get_commit_stats 暴露项目 commit 规范化统计(total/conventional/rework);治理面板常驻展示规范化比例 + 返工数,比例 <60% 提示种 lefthook。接入包 A/B/C1/C2/D 全部完成。
253 lines
8.6 KiB
Rust
253 lines
8.6 KiB
Rust
//! 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<String>,
|
||
/// scope(如 estate1/cad);None 表示无 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 与可选 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<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,
|
||
})
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|