feat(flywheel): L0 从 git 自吸执行质量(commit_metrics)
buff watcher 解析每条新 commit 的 conventional type/scope,将返工(fix)/CI自修信号写入 commit_metrics 表;append_usage_snapshot 的 rework_count 改由此表聚合,取代枯竭的复盘笔记解析。avg_rounds_M 暂留空(需 PR 边界,待档2)。含 parse_conventional 纯解析器 + 11 单测、project_id 索引、事务批量写入、首次限 50 条。
This commit is contained in:
parent
076bba7def
commit
3befc50d20
@ -99,11 +99,11 @@
|
||||
|
||||
---
|
||||
|
||||
### 📋 L0:Conventional Commit 执行质量解析层(数据地基,先于阶段三)
|
||||
- status: concept
|
||||
### ✅ L0:Conventional Commit 执行质量解析层(数据地基,先于阶段三)
|
||||
- status: done(2026-06-19,实现见 onboarding-pack 卡 C1+C2)
|
||||
- complexity: L
|
||||
- depends: onboarding-pack(项目须先种 lefthook + conventional 规范)
|
||||
- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs
|
||||
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/db.rs
|
||||
- acceptance: 炼境后端从本地 git 解析 conventional commit —— type=fix 计返工、scope 映射模块、commit 数≈轮数;自动填充 usage.json 的 rework_count/avg_rounds_M/complexity_dist,取代「从复盘笔记解析」的枯竭路径;CI 自修 commit(如 `[DeepSeek-V3]`)须从返工率剔除;squash merge 项目须降级处理
|
||||
|
||||
> 背景:实测复盘笔记进料口枯竭(70 条仅 4 条结构化)。改由 git 自吸,客户端无关、工作流无关,是阶段三预测层的真实数据基础。
|
||||
|
||||
@ -65,7 +65,7 @@ git 是「过去时 + 量化」(做了什么),蓝图是「将来时 + 语
|
||||
|
||||
## 任务卡
|
||||
|
||||
> 已过 /architect。下一步 /splitter 派发。A→B、C→D 两条链可并行;C(飞轮地基)建议优先。
|
||||
> 已过 /architect + /splitter。A→B、C1→C2 两条链可并行,D 依赖 C1+B;**C1(飞轮地基)建议优先开工**。
|
||||
|
||||
### 📋 A 模板分发引擎
|
||||
- status: planned
|
||||
@ -80,15 +80,22 @@ git 是「过去时 + 量化」(做了什么),蓝图是「将来时 + 语
|
||||
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectCard.tsx
|
||||
- acceptance: apply_onboarding_pack(project_path, project_id) 组合「模板分发 + apply_blueprint_buff」返回 OnboardingReport;看板卡片有"接入包"入口,展示分发报告(written/skipped/failed)
|
||||
|
||||
### 📋 C L0 Conventional Commit 解析
|
||||
- status: planned
|
||||
- complexity: L
|
||||
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/db.rs
|
||||
- acceptance: 新增 commit_metrics 表;buff watcher 的 check_project 用 last_commit..HEAD 取 message → 解析 type/scope/is_rework/is_ci_auto 写表;append_usage_snapshot 的 rework_count 改查 commit_metrics(is_rework=1 AND is_ci_auto=0);首次接入限解析最近 N 条
|
||||
### ✅ C1 commit_metrics 表 + Conventional 解析器
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/db.rs
|
||||
- acceptance: 新增 commit_metrics 表(sha PK / project_id / commit_type / scope / is_rework / is_ci_auto / committed_at / parsed_at);`parse_conventional(message)` 解析 type/scope,type=fix→is_rework,`[DeepSeek-V3]`/`[R1]` 等标记→is_ci_auto,非 conventional→commit_type=null 单列;提供聚合查询 `rework_count(project_id)`;解析逻辑纯函数可单测(覆盖 feat/fix/带scope/无scope/CI自修/非规范 六种输入)
|
||||
|
||||
### 📋 D 数据质量保护
|
||||
### ✅ C2 挂接 buff watcher + 飞轮快照
|
||||
- status: done
|
||||
- complexity: M
|
||||
- depends: onboarding-pack(卡 C1)
|
||||
- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs
|
||||
- acceptance: check_project 用 `last_commit..HEAD` 取 commit message(git log,复用现有 WSL 路径处理)→ 调 C1 解析写 commit_metrics;首次接入(last_commit 空)限解析最近 N 条;append_usage_snapshot 的 rework_count 改查 commit_metrics(is_rework=1 AND is_ci_auto=0)取代 parse_notes_metrics
|
||||
|
||||
### 📋 D 数据可信度提示
|
||||
- status: planned
|
||||
- complexity: S
|
||||
- depends: onboarding-pack(卡C)
|
||||
- files: src-tauri/src/commands/commit_metrics.rs
|
||||
- acceptance: CI 自修 commit([DeepSeek-V3] 等标记)置 is_ci_auto=1 从返工率剔除;非 conventional commit 记 commit_type=null 单列不污染 fix 率;接入报告提示"种 lefthook 后数据才准"
|
||||
- depends: onboarding-pack(卡 C1、卡 B)
|
||||
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/commit_metrics.rs
|
||||
- acceptance: OnboardingReport 与飞轮展示标注该项目 commit 规范化比例(conventional / 总数);比例低时提示"种 lefthook 后数据才准",避免脏数据被误读为可信(CI 过滤 / 非规范单列已在 C1 落实)
|
||||
|
||||
@ -2010,6 +2010,10 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String {
|
||||
|
||||
/// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。
|
||||
/// 返回 (rework_count, avg_rounds_m),avg_rounds_m 为 None 表示无 M 级复盘数据。
|
||||
///
|
||||
/// 旧路径:飞轮 L0 起 rework_count 改由 commit_metrics(git 自吸)提供,
|
||||
/// 此函数保留作参考与未来回退,暂不调用。
|
||||
#[allow(dead_code)]
|
||||
fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option<f64>) {
|
||||
let project_path = match bp_dir.parent().and_then(|p| p.to_str()) {
|
||||
Some(p) => p.to_string(),
|
||||
@ -2121,6 +2125,20 @@ fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option<f64>) {
|
||||
(rework_count, avg)
|
||||
}
|
||||
|
||||
/// 从项目路径反查 project_id,再聚合 commit_metrics 的有效返工次数。
|
||||
/// 项目未施加 buff(查不到 project_id)时返回 None。
|
||||
fn commit_rework_for_path(project_path: &str) -> Option<u32> {
|
||||
let conn = crate::db::pool().get().ok()?;
|
||||
let pid: String = conn
|
||||
.query_row(
|
||||
"SELECT project_id FROM blueprint_buffs WHERE project_path = ?1",
|
||||
rusqlite::params![project_path.trim_end_matches(['/', '\\'])],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
crate::commands::commit_metrics::rework_count(&conn, &pid).ok()
|
||||
}
|
||||
|
||||
/// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。
|
||||
/// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。
|
||||
fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) {
|
||||
@ -2201,8 +2219,14 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl
|
||||
}
|
||||
}
|
||||
|
||||
// 从 project_notes 解析返工次数和 M 级任务平均轮数
|
||||
let (rework_count, avg_rounds_m) = parse_notes_metrics(bp_dir);
|
||||
// 飞轮 L0:rework_count 从 commit_metrics(git 自吸)取,取代枯竭的复盘笔记解析;
|
||||
// avg_rounds_M 需 PR 边界,基线档暂不出(见 gitea-integration 档2)
|
||||
let rework_count = bp_dir
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.and_then(commit_rework_for_path)
|
||||
.unwrap_or(0);
|
||||
let avg_rounds_m: Option<f64> = None;
|
||||
|
||||
// 停滞模块:in_progress 且任务全为 in_progress/blocked(没有 todo 可推进)
|
||||
let stalled_modules: Vec<String> = manifest.modules.iter()
|
||||
|
||||
@ -131,47 +131,72 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
|
||||
Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过
|
||||
};
|
||||
|
||||
// 查询上次已处理的 commit
|
||||
// 查询 project_id 与上次已处理的 commit
|
||||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||
let last_commit: Option<String> = conn
|
||||
let (project_id, last_commit): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT last_commit FROM blueprint_buffs WHERE project_path = ?1",
|
||||
"SELECT project_id, last_commit FROM blueprint_buffs WHERE project_path = ?1",
|
||||
rusqlite::params![project_path],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap_or(None);
|
||||
.unwrap_or_else(|_| (String::new(), None));
|
||||
|
||||
// 没有变化,跳过
|
||||
if last_commit.as_deref() == Some(current_commit.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 获取变更文件列表
|
||||
let changed = get_changed_files(root, last_commit.as_deref())?;
|
||||
|
||||
let mut synced = false;
|
||||
if !changed.is_empty() {
|
||||
let updated = update_task_statuses(root, &changed)?;
|
||||
if updated > 0 {
|
||||
// 触发飞轮快照(调用 get_blueprint 会触发内部 append_usage_snapshot)
|
||||
let _ = crate::commands::blueprint::get_blueprint(project_path.to_string());
|
||||
|
||||
// 通知前端刷新
|
||||
use tauri::Emitter;
|
||||
let _ = app.emit(
|
||||
"blueprint://changed",
|
||||
serde_json::json!({ "project_path": project_path }),
|
||||
// 飞轮 L0:解析新增 commit 的 conventional 指标写 commit_metrics(与下方文件匹配互不干扰)
|
||||
if !project_id.is_empty() {
|
||||
let first_time = last_commit.is_none();
|
||||
let commits = get_new_commits(root, last_commit.as_deref());
|
||||
if first_time && commits.len() >= FIRST_TIME_LIMIT {
|
||||
crate::db::log_event(
|
||||
"buff",
|
||||
"info",
|
||||
&format!("首次接入仅解析最近 {FIRST_TIME_LIMIT} 条 commit,更早历史跳过"),
|
||||
Some(project_path),
|
||||
);
|
||||
synced = true;
|
||||
}
|
||||
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync)
|
||||
let tx = conn.unchecked_transaction();
|
||||
for (sha, subject, date) in &commits {
|
||||
let _ = crate::commands::commit_metrics::record_commit(
|
||||
&conn,
|
||||
&project_id,
|
||||
sha,
|
||||
subject,
|
||||
Some(date.as_str()),
|
||||
);
|
||||
}
|
||||
if let Ok(tx) = tx {
|
||||
let _ = tx.commit();
|
||||
}
|
||||
}
|
||||
|
||||
// 文件路径匹配 → 标记命中的任务卡 🔵
|
||||
let changed = get_changed_files(root, last_commit.as_deref())?;
|
||||
let mut tasks_updated = false;
|
||||
if !changed.is_empty() && update_task_statuses(root, &changed)? > 0 {
|
||||
tasks_updated = true;
|
||||
}
|
||||
|
||||
// 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘
|
||||
let _ = crate::commands::blueprint::get_blueprint(project_path.to_string());
|
||||
if tasks_updated {
|
||||
use tauri::Emitter;
|
||||
let _ = app.emit(
|
||||
"blueprint://changed",
|
||||
serde_json::json!({ "project_path": project_path }),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新 DB:记录已处理的 commit
|
||||
let _ = conn.execute(
|
||||
"UPDATE blueprint_buffs
|
||||
SET last_commit = ?1, last_sync_at = CASE WHEN ?3 THEN datetime('now') ELSE last_sync_at END
|
||||
SET last_commit = ?1, last_sync_at = datetime('now')
|
||||
WHERE project_path = ?2",
|
||||
rusqlite::params![current_commit, project_path, synced],
|
||||
rusqlite::params![current_commit, project_path],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@ -262,6 +287,64 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<Strin
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助:新增 commit 解析(飞轮 L0)─────────────────────────────────────────
|
||||
|
||||
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
||||
const FIRST_TIME_LIMIT: usize = 50;
|
||||
|
||||
/// 获取 last_commit..HEAD 之间的新 commit(sha, subject, committed_at ISO)。
|
||||
/// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。
|
||||
fn get_new_commits(root: &Path, last_commit: Option<&str>) -> Vec<(String, String, String)> {
|
||||
// sha \x1f subject \x1f committer-date(ISO),每条 commit 一行
|
||||
const FMT: &str = "--format=%H%x1f%s%x1f%cI";
|
||||
let path_str = root.to_string_lossy();
|
||||
|
||||
let out = if is_wsl_path(&path_str) {
|
||||
let linux = match unc_to_linux(&path_str) {
|
||||
Some(l) => l,
|
||||
None => return vec![],
|
||||
};
|
||||
let range = match last_commit {
|
||||
Some(prev) => format!("{prev}..HEAD"),
|
||||
None => format!("-n {FIRST_TIME_LIMIT}"),
|
||||
};
|
||||
let cmd = format!("git -C {linux} log {range} {FMT}");
|
||||
crate::silent_cmd("wsl").args(["bash", "-c", &cmd]).output()
|
||||
} else {
|
||||
let mut cmd = crate::silent_cmd("git");
|
||||
cmd.arg("log");
|
||||
match last_commit {
|
||||
Some(prev) => {
|
||||
cmd.arg(format!("{prev}..HEAD"));
|
||||
}
|
||||
None => {
|
||||
cmd.arg("-n").arg(FIRST_TIME_LIMIT.to_string());
|
||||
}
|
||||
}
|
||||
cmd.arg(FMT);
|
||||
cmd.current_dir(root).output()
|
||||
};
|
||||
|
||||
let out = match out {
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
|
||||
|
||||
fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result<usize, String> {
|
||||
|
||||
231
src-tauri/src/commands/commit_metrics.rs
Normal file
231
src-tauri/src/commands/commit_metrics.rs
Normal file
@ -0,0 +1,231 @@
|
||||
//! Conventional Commit 解析与执行质量指标(飞轮 L0 数据地基)。
|
||||
//!
|
||||
//! 从 git commit message 解析出 type/scope/返工/CI 自修信号,写入 commit_metrics 表,
|
||||
//! 取代枯竭的「复盘笔记解析」路径。纯解析逻辑(`parse_conventional`)与 DB 无关,可独立单测。
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// 一条 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)使用。
|
||||
#[allow(dead_code)]
|
||||
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))
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ pub mod tags;
|
||||
pub mod cicd;
|
||||
pub mod blueprint;
|
||||
pub mod buff;
|
||||
pub mod commit_metrics;
|
||||
pub mod canvas;
|
||||
pub mod claude_config;
|
||||
pub mod mentor;
|
||||
|
||||
@ -520,6 +520,21 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
);
|
||||
").expect("create blueprint_buffs table");
|
||||
|
||||
// 飞轮 L0:从 git 解析的 conventional commit 执行质量指标
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS commit_metrics (
|
||||
sha TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
commit_type TEXT,
|
||||
scope TEXT,
|
||||
is_rework INTEGER NOT NULL DEFAULT 0,
|
||||
is_ci_auto INTEGER NOT NULL DEFAULT 0,
|
||||
committed_at TEXT,
|
||||
parsed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commit_metrics_project ON commit_metrics(project_id);
|
||||
").expect("create commit_metrics table");
|
||||
|
||||
// 仪表盘全局快启应用(无项目 FK 约束)
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS global_tools (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user