dev-manager-tauri/src-tauri/src/commands/commit_metrics.rs
2026-09-21 12:52:37 +09:00

1375 lines
48 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};
use std::collections::HashSet;
/// 一条 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,
}
}
/// 把 trailer 原始值(如 "R01, r06 R01")归一化为大写去重逗号串。
/// 只接受 R+数字 形式的 token;无有效 token 返回 None。
pub fn normalize_rule_tokens(raw: &str) -> Option<String> {
let mut ids: Vec<String> = raw
.split([',', ' ', ','])
.map(|t| t.trim().to_uppercase())
.filter(|t| {
t.len() >= 2 && t.starts_with('R') && t[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
None
} else {
Some(ids.join(","))
}
}
/// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer(飞轮 R1 规则有效性数据源)。
/// 只接受 R+数字 形式的 token,规范化为大写去重逗号串;无 trailer 或无有效 token 返回 None。
pub fn parse_rules_applied(message: &str) -> Option<String> {
for line in message.lines() {
let trimmed = line.trim();
let Some(rest) = trimmed
.strip_prefix("Rules-Applied:")
.or_else(|| trimmed.strip_prefix("rules-applied:"))
.or_else(|| trimmed.strip_prefix("Rules-applied:"))
else {
continue;
};
return normalize_rule_tokens(rest);
}
None
}
// ── U3:Rules-Applied 真实性校验(ingest 层单点,防幻觉/typo ID 污染交叉统计)──
/// 从 CONVENTIONS.md 内容提取全部规则 ID(大写 R+数字,前后不粘字母数字)。纯函数。
pub fn extract_rule_ids(text: &str) -> HashSet<String> {
let b = text.as_bytes();
let mut ids = HashSet::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'R' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
let mut j = i + 1;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 1 && (j == b.len() || !b[j].is_ascii_alphanumeric()) {
ids.insert(text[i..j].to_string());
}
i = j;
} else {
i += 1;
}
}
ids
}
/// 过滤 rules_applied 串:剔除不在 valid 集合中的 ID;全部无效返回 None。
/// valid 为 None(项目无 CONVENTIONS.md)时不校验、原样保留。纯函数。
pub fn filter_valid_rules(rules: &str, valid: Option<&HashSet<String>>) -> Option<String> {
let Some(set) = valid else {
return Some(rules.to_string());
};
let kept: Vec<&str> = rules
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty() && set.contains(*t))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(","))
}
}
/// 读取项目 .blueprint/CONVENTIONS.md 提取规则 ID 集合;文件不存在返回 None(不校验)。
/// 每项目每批次调用一次,勿放进逐 commit 循环。
pub fn load_project_rule_ids(root: &std::path::Path) -> Option<HashSet<String>> {
let content = std::fs::read_to_string(root.join(".blueprint").join("CONVENTIONS.md")).ok()?;
Some(extract_rule_ids(&content))
}
/// 远程采集链路(webhook/轮询)无本地 root,按 project_id 查 blueprint_buffs 路径再加载。
pub fn load_rule_ids_by_project_id(conn: &Connection, project_id: &str) -> Option<HashSet<String>> {
let path: String = conn
.query_row(
"SELECT project_path FROM blueprint_buffs WHERE project_id = ?1",
rusqlite::params![project_id],
|r| r.get(0),
)
.ok()?;
load_project_rule_ids(std::path::Path::new(&path))
}
/// 回溯清洗存量行:剔除 rules_applied 中不在 valid 集合的 ID(全无效置 NULL)。
/// 与 UPSERT 的「不覆盖已有值」约束并行不悖——本函数是显式清洗通道,仅在全量导入时调用。
/// 返回被清洗的行数,重跑幂等(已干净的行不再计数)。
pub fn sanitize_rules_applied(
conn: &Connection,
project_id: &str,
valid: &HashSet<String>,
) -> Result<u32, String> {
let rows: Vec<(String, String)> = {
let mut stmt = conn
.prepare(
"SELECT sha, rules_applied FROM commit_metrics
WHERE project_id = ?1 AND rules_applied IS NOT NULL",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map(rusqlite::params![project_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.map_err(|e| e.to_string())?;
mapped.filter_map(|r| r.ok()).collect()
};
let mut cleaned = 0u32;
for (sha, rules) in rows {
let filtered = filter_valid_rules(&rules, Some(valid));
if filtered.as_deref() != Some(rules.as_str()) {
conn.execute(
"UPDATE commit_metrics SET rules_applied = ?1 WHERE sha = ?2",
rusqlite::params![filtered, sha],
)
.map_err(|e| e.to_string())?;
cleaned += 1;
}
}
Ok(cleaned)
}
/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填
/// rules_applied(绝不覆盖已有值——回填幂等的关键约束)。返回是否产生了写入。
fn upsert_metrics(
conn: &Connection,
project_id: &str,
sha: &str,
parsed: &ParsedCommit,
rules_applied: Option<&str>,
committed_at: Option<&str>,
) -> Result<bool, String> {
let n = conn
.execute(
"INSERT INTO commit_metrics
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(sha) DO UPDATE SET rules_applied = excluded.rules_applied
WHERE commit_metrics.rules_applied IS NULL
AND excluded.rules_applied IS NOT NULL",
rusqlite::params![
sha,
project_id,
parsed.commit_type,
parsed.scope,
parsed.is_rework as i32,
parsed.is_ci_auto as i32,
committed_at,
rules_applied,
],
)
.map_err(|e| e.to_string())?;
Ok(n > 0)
}
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer),
/// 并按 valid_rules 剔除未知规则 ID(U3)。远程采集生产链路(webhook/轮询)用本函数,
/// valid_rules 每项目加载一次(`load_rule_ids_by_project_id`)。
pub fn record_commit_validated(
conn: &Connection,
project_id: &str,
sha: &str,
message: &str,
committed_at: Option<&str>,
valid_rules: Option<&HashSet<String>>,
) -> Result<(), String> {
let p = parse_conventional(message);
let rules_applied =
parse_rules_applied(message).and_then(|r| filter_valid_rules(&r, valid_rules));
upsert_metrics(
conn,
project_id,
sha,
&p,
rules_applied.as_deref(),
committed_at,
)?;
Ok(())
}
/// 不校验规则 ID 的写入(等价 valid_rules=None)。仅供测试铺数据;
/// 生产链路一律走 `record_commit_validated` / `record_commit_entry`(U3 校验强制)。
#[cfg(test)]
pub fn record_commit(
conn: &Connection,
project_id: &str,
sha: &str,
message: &str,
committed_at: Option<&str>,
) -> Result<(), String> {
record_commit_validated(conn, project_id, sha, message, committed_at, None)
}
/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
/// valid_rules 每项目加载一次传入(U3 校验);None 表示项目无 CONVENTIONS.md,不校验。
pub fn record_commit_entry(
conn: &Connection,
project_id: &str,
entry: &GitLogEntry,
valid_rules: Option<&HashSet<String>>,
) -> Result<bool, String> {
let p = parse_conventional(&entry.subject);
let rules = normalize_rule_tokens(&entry.rules_trailer)
.and_then(|r| filter_valid_rules(&r, valid_rules));
let date = if entry.committed_at.is_empty() {
None
} else {
Some(entry.committed_at.as_str())
};
let changed = upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)?;
for path in &entry.files {
conn.execute(
"INSERT OR IGNORE INTO commit_files (sha, path) VALUES (?1, ?2)",
rusqlite::params![entry.sha, path],
)
.map_err(|e| e.to_string())?;
}
Ok(changed)
}
/// 单条规则的命中统计(跨项目聚合)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RuleHitStat {
pub rule_id: String,
/// 附带该规则的 commit 总数
pub hits: u32,
/// 其中 type=fix(非 CI 自修)的返工 commit 数——规则触发了但仍出问题的信号
pub rework_hits: u32,
/// 覆盖的项目数
pub project_count: u32,
}
/// 聚合所有项目的 Rules-Applied 命中 × 返工交叉统计(供梦核置信度分析)。
pub fn rule_hit_stats(conn: &Connection) -> Result<Vec<RuleHitStat>, String> {
let mut stmt = conn
.prepare(
"SELECT rules_applied, is_rework, is_ci_auto, project_id
FROM commit_metrics WHERE rules_applied IS NOT NULL",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)? != 0,
r.get::<_, i64>(2)? != 0,
r.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
use std::collections::{BTreeMap, HashSet};
let mut hits: BTreeMap<String, (u32, u32, HashSet<String>)> = BTreeMap::new();
for row in rows {
let (rules, is_rework, is_ci_auto, project_id) = row.map_err(|e| e.to_string())?;
for rule in rules.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let entry = hits.entry(rule.to_string()).or_default();
entry.0 += 1;
if is_rework && !is_ci_auto {
entry.1 += 1;
}
entry.2.insert(project_id.clone());
}
}
Ok(hits
.into_iter()
.map(|(rule_id, (h, rw, projects))| RuleHitStat {
rule_id,
hits: h,
rework_hits: rw,
project_count: projects.len() as u32,
})
.collect())
}
/// 项目的有效返工次数: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 数(新插入 + rules_applied 回填)
pub imported: u32,
/// 已存在且无需回填(跳过)
pub skipped: u32,
/// 存量行中被清洗掉未知规则 ID 的行数(U3 回溯清洗;项目无 CONVENTIONS.md 时恒为 0)
pub sanitized: u32,
}
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
/// `files` 来自 --name-only(P3-B 文件级风险画像数据源),merge commit 为空。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLogEntry {
pub sha: String,
pub subject: String,
pub committed_at: String,
pub rules_trailer: String,
pub files: Vec<String>,
}
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。
pub enum GitLogRange<'a> {
Limit(usize),
Since(&'a str),
}
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔)。
/// 配合 --name-only:header 行后跟随该 commit 触碰的文件路径行。
const GIT_LOG_FMT: &str =
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。
/// 规则:含 \x1f 的行是 commit header;其后的非空行是该 commit 的文件路径。
pub fn parse_git_log_output(output: &str) -> Vec<GitLogEntry> {
let mut entries: Vec<GitLogEntry> = Vec::new();
for line in output.lines() {
if line.contains('\u{1f}') {
let mut parts = line.split('\u{1f}');
let sha = parts.next().unwrap_or("").trim().to_string();
if sha.is_empty() {
continue;
}
entries.push(GitLogEntry {
sha,
subject: parts.next().unwrap_or("").to_string(),
committed_at: parts.next().unwrap_or("").to_string(),
rules_trailer: parts.next().unwrap_or("").trim().to_string(),
files: Vec::new(),
});
} else {
let path = line.trim();
if !path.is_empty() {
if let Some(cur) = entries.last_mut() {
cur.files.push(path.to_string());
}
}
}
}
entries
}
/// 读取 git log(含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
pub fn read_git_log(
root: &std::path::Path,
range: GitLogRange,
) -> Result<Vec<GitLogEntry>, String> {
let mut cmd = crate::silent_cmd("git");
cmd.arg("log");
match range {
GitLogRange::Limit(n) => {
cmd.args(["-n", &n.to_string()]);
}
GitLogRange::Since(prev) => {
cmd.arg(format!("{prev}..HEAD"));
}
}
let out = cmd
.args([GIT_LOG_FMT, "--name-only"])
.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(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
}
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
/// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE)。
/// 若该路径在 blueprint_buffs 中已有 UUID project_id,优先用 UUID,保持与增量写入一致。
#[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, GitLogRange::Limit(FULL_HISTORY_LIMIT))?;
let total = commits.len() as u32;
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
// 优先用 blueprint_buffs 里的 UUID project_id,避免同一 sha 用两个不同 project_id 冲突
let effective_id = conn
.query_row(
"SELECT project_id FROM blueprint_buffs WHERE project_path = ?1",
rusqlite::params![project_path],
|r| r.get::<_, String>(0),
)
.unwrap_or(project_id);
// U3:每项目加载一次规则 ID 集合,新写入过滤 + 存量回溯清洗
let valid_rules = load_project_rule_ids(root);
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut imported = 0u32;
for entry in &commits {
// 新 sha 插入;已存在的行 UPSERT 回填 rules_applied(trailer 死链修复后的存量补数路径)
if record_commit_entry(&conn, &effective_id, entry, valid_rules.as_ref())? {
imported += 1;
}
}
let sanitized = match &valid_rules {
Some(set) => sanitize_rules_applied(&conn, &effective_id, set)?,
None => 0,
};
tx.commit().map_err(|e| e.to_string())?;
Ok(IngestResult {
imported,
skipped: total - imported,
sanitized,
})
}
// ── 跨项目 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<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)
}
/// commit_metrics 中所有项目的健康度(含项目名解析)。
#[derive(Debug, Serialize)]
pub struct CommitHealthWithName {
pub project_id: String,
/// 解析出的项目名:优先从 projects 表直接匹配,其次通过 blueprint_buffs 路径关联 projects,再次用路径末段
pub project_name: String,
pub total_commits: u32,
pub conventional_commits: u32,
pub conventional_rate: f64,
pub rework_commits: u32,
pub rework_rate: f64,
pub top_scopes: Vec<ScopeActivity>,
}
/// 查询 commit_metrics 里所有有数据的项目,自动关联名称。无需前端传 project_ids。
#[tauri::command]
pub fn get_all_commit_health() -> Result<Vec<CommitHealthWithName>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let round3 = |v: f64| (v * 1000.0).round() / 1000.0;
// 聚合各 project_id 的指标,四条路径解析名称:
// 1) projects 直接匹配(名称字符串 id)
// 2) blueprint_buffs + projects 路径关联
// 3) project_profiles "prof-UUID" 格式(孤儿 UUID 的名称来源)
// 4) blueprint_buffs.project_path(后处理提取末段目录名)
let mut stmt = conn
.prepare(
"SELECT
cm.project_id,
COALESCE(
p_direct.name,
p_via_buff.name,
pp_prof.name,
bb.project_path,
cm.project_id
) AS name,
COUNT(*) AS total,
SUM(CASE WHEN cm.commit_type IS NOT NULL THEN 1 ELSE 0 END) AS conventional,
SUM(CASE WHEN cm.is_rework=1 AND cm.is_ci_auto=0 THEN 1 ELSE 0 END) AS rework
FROM commit_metrics cm
LEFT JOIN projects p_direct ON p_direct.id = cm.project_id
LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id
LEFT JOIN projects p_via_buff
ON p_via_buff.win_path = bb.project_path
OR p_via_buff.wsl_path = bb.project_path
LEFT JOIN project_profiles pp_prof ON pp_prof.id = ('prof-' || cm.project_id)
GROUP BY cm.project_id
ORDER BY total DESC",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(String, String, u32, u32, u32)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let mut result = Vec::with_capacity(rows.len());
for (pid, name, total, conventional, rework) in rows {
// 查 top_scopes
let mut scope_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> = scope_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();
// 如果 name 解析为路径(含 / 或 \),取末段目录名
let display_name = if name.contains('/') || name.contains('\\') {
name.split(['/', '\\'])
.filter(|s| !s.is_empty())
.last()
.unwrap_or(&name)
.to_string()
} else {
name
};
result.push(CommitHealthWithName {
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,
project_name: display_name,
total_commits: total,
conventional_commits: conventional,
rework_commits: rework,
top_scopes,
});
}
Ok(result)
}
#[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 重复写入应被忽略");
}
// ── P3-A:trailer 链路修复 + UPSERT 回填 ─────────────────────────────────────
fn entry(sha: &str, subject: &str, trailer: &str) -> GitLogEntry {
GitLogEntry {
sha: sha.into(),
subject: subject.into(),
committed_at: "2026-07-04T00:00:00+09:00".into(),
rules_trailer: trailer.into(),
files: Vec::new(),
}
}
fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option<String> {
conn.query_row(
"SELECT rules_applied FROM commit_metrics WHERE sha = ?1",
[sha],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn parse_git_log_output_headers_trailers_and_files() {
// 两条 commit:首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
\n\
src/a.rs\n\
src/b.rs\n\
def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}\n\
\n\
src/c.rs\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].sha, "abc");
assert_eq!(entries[0].subject, "feat(x): y");
assert_eq!(entries[0].rules_trailer, "R01,R06");
assert_eq!(entries[0].files, vec!["src/a.rs", "src/b.rs"]);
assert_eq!(entries[1].rules_trailer, "", "无 trailer 时为空串");
assert_eq!(entries[1].files, vec!["src/c.rs"]);
assert!(parse_git_log_output("").is_empty());
// 空 commit(merge / --allow-empty):header 后无文件行
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
assert!(no_files[0].files.is_empty());
}
#[test]
fn normalize_rule_tokens_filters_and_dedups() {
assert_eq!(
normalize_rule_tokens("R01, r06 R01").as_deref(),
Some("R01,R06")
);
assert_eq!(normalize_rule_tokens("无效, abc"), None);
assert_eq!(normalize_rule_tokens(""), None);
}
#[test]
fn upsert_backfills_null_rules_only() {
let conn = crate::db::conn_with_schema();
// 模拟存量行:%s 时代写入,rules_applied 为 NULL
record_commit(&conn, "proj", "old", "feat(a): x", None).unwrap();
assert_eq!(rules_of(&conn, "old"), None);
// 回填:同 sha 带 trailer 重写 → 补上
let changed =
record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06"), None)
.unwrap();
assert!(changed, "回填应计为写入");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 不覆盖:已有值的行,不同 trailer 重写 → 保持原值
let changed =
record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99"), None).unwrap();
assert!(!changed, "已有值不应被覆盖");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE
record_commit(&conn, "proj", "old2", "chore: y", None).unwrap();
let changed =
record_commit_entry(&conn, "proj", &entry("old2", "chore: y", ""), None).unwrap();
assert!(!changed);
// 新行带 trailer 直接写入
assert!(
record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05"), None).unwrap()
);
assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05"));
}
/// 端到端:真 git 仓库验证 %(trailers) 提取——这是 R1 死链(%s 只取 subject)的回归测试。
#[test]
fn read_git_log_extracts_trailer_from_real_repo() {
let dir = std::env::temp_dir().join(format!("lj-trailer-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(&dir)
.output()
.expect("git 可执行");
assert!(
out.status.success(),
"git {:?} 失败: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q"]);
git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"--allow-empty",
"-q",
"-m",
"feat(a): no trailer",
]);
std::fs::write(dir.join("x.rs"), "// x").unwrap();
std::fs::write(dir.join("y.rs"), "// y").unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"fix(b): with trailer",
"-m",
"Rules-Applied: R01,R06",
]);
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(entries.len(), 2);
// git log 逆序:最新在前
assert_eq!(entries[0].subject, "fix(b): with trailer");
assert_eq!(
entries[0].rules_trailer, "R01,R06",
"trailer 必须被提取(%s 死链回归)"
);
assert_eq!(
entries[0].files,
vec!["x.rs", "y.rs"],
"--name-only 文件必须被采集"
);
assert_eq!(entries[1].rules_trailer, "");
assert!(entries[1].files.is_empty(), "空 commit 无文件");
}
#[test]
fn commit_files_written_and_idempotent() {
let conn = crate::db::conn_with_schema();
let mut e = entry("s1", "feat(a): x", "");
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
record_commit_entry(&conn, "proj", &e, None).unwrap();
record_commit_entry(&conn, "proj", &e, None).unwrap(); // 重跑
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_files WHERE sha = 's1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 2, "文件写入且重跑幂等");
}
#[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 忽略");
}
// ── R1:Rules-Applied trailer ──────────────────────────────────────────
#[test]
fn parse_rules_applied_normalizes_tokens() {
let msg = "feat(core): thing
说明
Rules-Applied: r06, R01,R01, xx, R10";
assert_eq!(parse_rules_applied(msg).as_deref(), Some("R01,R06,R10"));
}
#[test]
fn parse_rules_applied_absent_or_empty() {
assert_eq!(parse_rules_applied("feat: no trailer"), None);
assert_eq!(parse_rules_applied("Rules-Applied: 无效, abc"), None);
}
#[test]
fn record_commit_stores_rules_applied() {
let conn = crate::db::conn_with_schema();
record_commit(
&conn,
"pr",
"sha-r1",
"feat(x): a
Rules-Applied: R01,R05",
None,
)
.unwrap();
let stored: Option<String> = conn
.query_row(
"SELECT rules_applied FROM commit_metrics WHERE sha = 'sha-r1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(stored.as_deref(), Some("R01,R05"));
}
#[test]
fn rule_hit_stats_cross_counts_rework() {
let conn = crate::db::conn_with_schema();
record_commit(
&conn,
"pa",
"s1",
"feat(x): ok
Rules-Applied: R01",
None,
)
.unwrap();
record_commit(
&conn,
"pa",
"s2",
"fix(x): broke
Rules-Applied: R01,R06",
None,
)
.unwrap();
record_commit(
&conn,
"pb",
"s3",
"feat(y): fine
Rules-Applied: R06",
None,
)
.unwrap();
// CI 自修的 fix 不计返工
record_commit(
&conn,
"pb",
"s4",
"fix(y): auto [DeepSeek-V3]
Rules-Applied: R06",
None,
)
.unwrap();
let stats = rule_hit_stats(&conn).unwrap();
let r01 = stats.iter().find(|s| s.rule_id == "R01").unwrap();
assert_eq!(r01.hits, 2);
assert_eq!(r01.rework_hits, 1);
assert_eq!(r01.project_count, 1);
let r06 = stats.iter().find(|s| s.rule_id == "R06").unwrap();
assert_eq!(r06.hits, 3);
assert_eq!(r06.rework_hits, 1, "CI 自修 fix 不应计入返工");
assert_eq!(r06.project_count, 2);
}
#[test]
fn rule_hit_stats_empty_when_no_trailers() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "pz", "s9", "feat: plain", None).unwrap();
assert!(rule_hit_stats(&conn).unwrap().is_empty());
}
// ── U3:Rules-Applied 真实性校验 ────────────────────────────────────────
fn valid_set(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn extract_rule_ids_respects_token_boundaries() {
let text = "### R01 规则一\n- R12:xxx(见 |R06| 表格)\nPR13 前粘字母不算,R2D2 后粘字母不算,r03 小写不算";
let ids = extract_rule_ids(text);
assert!(ids.contains("R01"));
assert!(ids.contains("R12"));
assert!(ids.contains("R06"));
assert!(!ids.contains("R13"), "PR13 内的 R13 不是独立 ID");
assert!(!ids.contains("R2"), "R2D2 内的 R2 不是独立 ID");
assert!(
!ids.contains("R03") && !ids.contains("r03"),
"小写 r 不提取"
);
assert!(extract_rule_ids("没有任何规则").is_empty());
}
#[test]
fn filter_valid_rules_drops_unknown_ids() {
let set = valid_set(&["R01", "R06"]);
assert_eq!(
filter_valid_rules("R01,R99", Some(&set)).as_deref(),
Some("R01")
);
assert_eq!(
filter_valid_rules("R99", Some(&set)),
None,
"全无效返回 None"
);
assert_eq!(
filter_valid_rules("R01,R99", None).as_deref(),
Some("R01,R99"),
"无 CONVENTIONS.md 时不校验"
);
}
#[test]
fn record_commit_entry_filters_unknown_ids_at_ingest() {
let conn = crate::db::conn_with_schema();
let set = valid_set(&["R01"]);
record_commit_entry(
&conn,
"proj",
&entry("v1", "feat(a): x", "R01,R99"),
Some(&set),
)
.unwrap();
assert_eq!(
rules_of(&conn, "v1").as_deref(),
Some("R01"),
"未知 ID 应在入库前剔除"
);
record_commit_entry(&conn, "proj", &entry("v2", "feat(a): y", "R99"), Some(&set)).unwrap();
assert_eq!(rules_of(&conn, "v2"), None, "全无效则该列为 NULL");
}
#[test]
fn record_commit_validated_filters_trailer_in_message() {
let conn = crate::db::conn_with_schema();
let set = valid_set(&["R05"]);
record_commit_validated(
&conn,
"proj",
"w1",
"feat(x): a\n\nRules-Applied: R05,R99",
None,
Some(&set),
)
.unwrap();
assert_eq!(rules_of(&conn, "w1").as_deref(), Some("R05"));
}
#[test]
fn sanitize_cleans_stored_pollution_idempotently() {
let conn = crate::db::conn_with_schema();
// 存量污染:校验上线前写入的行
record_commit_entry(&conn, "proj", &entry("p1", "feat: a", "R01,R99"), None).unwrap();
record_commit_entry(&conn, "proj", &entry("p2", "fix: b", "R99"), None).unwrap();
record_commit_entry(&conn, "proj", &entry("p3", "feat: c", "R01"), None).unwrap();
record_commit_entry(&conn, "other", &entry("q1", "feat: d", "R99"), None).unwrap();
let set = valid_set(&["R01"]);
let cleaned = sanitize_rules_applied(&conn, "proj", &set).unwrap();
assert_eq!(cleaned, 2, "p1 剔除 R99、p2 置 NULL;p3 干净不动");
assert_eq!(rules_of(&conn, "p1").as_deref(), Some("R01"));
assert_eq!(rules_of(&conn, "p2"), None);
assert_eq!(rules_of(&conn, "p3").as_deref(), Some("R01"));
assert_eq!(
rules_of(&conn, "q1").as_deref(),
Some("R99"),
"只清洗指定项目"
);
// 清洗后交叉统计不再含未知 ID(acceptance 断言)
let stats = rule_hit_stats(&conn).unwrap();
assert!(
stats
.iter()
.filter(|s| s.rule_id == "R99")
.all(|s| s.project_count == 1),
"proj 的 R99 已清,仅剩 other 项目的"
);
// 幂等:重跑零清洗
assert_eq!(sanitize_rules_applied(&conn, "proj", &set).unwrap(), 0);
}
}