- 新表 commit_files(sha,path 联合主键 + path 索引),migration 入 migrate() - read_git_log 加 --name-only:单次 git 调用同时取 conventional 指标、 Rules-Applied trailer 与触碰文件,零额外进程开销 - parse_git_log_output 纯函数解析 header/文件行混排输出(含 \x1f 的行 是 header,其后非空行归属该 commit) - record_commit_entry 落库文件清单(INSERT OR IGNORE 幂等),buff 增量 与 ingest 全量两条链路自动覆盖,无需各自改动 Rules-Applied: R05,R06,R07
995 lines
38 KiB
Rust
995 lines
38 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,
|
||
}
|
||
}
|
||
|
||
/// 把 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
|
||
}
|
||
|
||
/// 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)。
|
||
/// 生产链路的 git log 只给 subject,trailer 走 `record_commit_entry`;本函数保留给
|
||
/// 持有完整 message 的调用方与测试。
|
||
pub fn record_commit(
|
||
conn: &Connection,
|
||
project_id: &str,
|
||
sha: &str,
|
||
message: &str,
|
||
committed_at: Option<&str>,
|
||
) -> Result<(), String> {
|
||
let p = parse_conventional(message);
|
||
let rules_applied = parse_rules_applied(message);
|
||
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。
|
||
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
|
||
pub fn record_commit_entry(
|
||
conn: &Connection,
|
||
project_id: &str,
|
||
entry: &GitLogEntry,
|
||
) -> Result<bool, String> {
|
||
let p = parse_conventional(&entry.subject);
|
||
let rules = normalize_rule_tokens(&entry.rules_trailer);
|
||
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,
|
||
}
|
||
|
||
/// 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);
|
||
|
||
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)? {
|
||
imported += 1;
|
||
}
|
||
}
|
||
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<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")).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")).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", "")).unwrap();
|
||
assert!(!changed);
|
||
|
||
// 新行带 trailer 直接写入
|
||
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).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).unwrap();
|
||
record_commit_entry(&conn, "proj", &e).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());
|
||
}
|
||
}
|
||
|