feat(flywheel-L0): F1 全量历史导入 + 跨项目 Git 健康度聚合
- ingest_full_git_history: 最多 500 条历史,INSERT OR IGNORE 幂等,事务批量写入 - get_project_commit_health: 直接聚合 commit_metrics,返回规范化率/返工率/top_scopes - 前端 commands.ts 补 IngestResult/ProjectCommitHealth/ScopeActivity 封装 - 新增 4 个单元测试(空项目排除、聚合正确性、scope 排序、写入幂等)
This commit is contained in:
parent
cc5d4a219b
commit
9c2064e3d0
@ -305,6 +305,7 @@ modules:
|
|||||||
area: backend
|
area: backend
|
||||||
status: in_progress
|
status: in_progress
|
||||||
progress: 86
|
progress: 86
|
||||||
|
note: "L0 数据层已完成;F1+F2(展示层)待做;阶段三待数据积累"
|
||||||
position: [1750, 650]
|
position: [1750, 650]
|
||||||
|
|
||||||
- id: cloud-products
|
- id: cloud-products
|
||||||
|
|||||||
@ -110,6 +110,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 📋 F1. Git 健康度后端——全量历史导入 + 跨项目聚合
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||||
|
- acceptance: 新增 `ingest_full_git_history(project_id, project_path)` 命令(最多 500 条,INSERT OR IGNORE 幂等);新增 `get_project_commit_health(project_ids)` 命令返回各项目 conventional_rate/rework_rate/top_scopes;单元测试覆盖 ingest 幂等性和 health 聚合
|
||||||
|
|
||||||
|
### 📋 F2. 飞轮面板——Git 健康度区块 + 全量导入按钮
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: F1
|
||||||
|
- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx
|
||||||
|
- acceptance: FlywheelPanel 底部新增"Git 执行质量"卡片,表格展示各项目 commit 规范化率/返工率/TOP scope;"全量导入"按钮对所有已接入项目触发 ingest_full_git_history,显示 loading 和结果摘要;无类型错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 阶段三:执行质量预测层
|
### 阶段三:执行质量预测层
|
||||||
- status: todo
|
- status: todo
|
||||||
- complexity: L
|
- complexity: L
|
||||||
|
|||||||
@ -154,6 +154,186 @@ pub fn get_commit_stats(project_id: String) -> Result<CommitStats, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 全量历史导入 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FULL_HISTORY_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
/// 全量历史补录结果。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct IngestResult {
|
||||||
|
/// 本次新写入 commit 数
|
||||||
|
pub imported: u32,
|
||||||
|
/// 已存在(INSERT OR IGNORE 跳过)
|
||||||
|
pub skipped: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 git log 最近 limit 条:返回 (sha, subject, committed_at_iso)。
|
||||||
|
fn read_git_log(root: &std::path::Path, limit: usize) -> Result<Vec<(String, String, String)>, String> {
|
||||||
|
const FMT: &str = "--format=%H%x1f%s%x1f%cI";
|
||||||
|
let out = crate::silent_cmd("git")
|
||||||
|
.args(["log", "-n", &limit.to_string(), FMT])
|
||||||
|
.current_dir(root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("git log 失败: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let mut parts = line.split('\u{1f}');
|
||||||
|
let sha = parts.next()?.trim().to_string();
|
||||||
|
if sha.is_empty() { return None; }
|
||||||
|
let subject = parts.next().unwrap_or("").to_string();
|
||||||
|
let date = parts.next().unwrap_or("").to_string();
|
||||||
|
Some((sha, subject, date))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
|
||||||
|
/// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ingest_full_git_history(
|
||||||
|
project_id: String,
|
||||||
|
project_path: String,
|
||||||
|
) -> Result<IngestResult, String> {
|
||||||
|
let root = std::path::Path::new(project_path.trim_end_matches(['/', '\\']));
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(format!("目录不存在: {}", root.display()));
|
||||||
|
}
|
||||||
|
let commits = read_git_log(root, FULL_HISTORY_LIMIT)?;
|
||||||
|
let total = commits.len() as u32;
|
||||||
|
|
||||||
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||||
|
let mut imported = 0u32;
|
||||||
|
for (sha, subject, date) in &commits {
|
||||||
|
let p = parse_conventional(subject);
|
||||||
|
let n = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO commit_metrics
|
||||||
|
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
rusqlite::params![
|
||||||
|
sha, project_id, p.commit_type, p.scope,
|
||||||
|
p.is_rework as i32, p.is_ci_auto as i32,
|
||||||
|
if date.is_empty() { None } else { Some(date.as_str()) },
|
||||||
|
],
|
||||||
|
).map_err(|e| e.to_string())?;
|
||||||
|
imported += n as u32;
|
||||||
|
}
|
||||||
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
|
Ok(IngestResult { imported, skipped: total - imported })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 跨项目 Git 健康度聚合 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 单个 scope 的活跃度摘要。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScopeActivity {
|
||||||
|
pub scope: String,
|
||||||
|
pub commit_count: u32,
|
||||||
|
pub rework_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单项目 Git 健康度指标。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectCommitHealth {
|
||||||
|
pub project_id: String,
|
||||||
|
pub total_commits: u32,
|
||||||
|
pub conventional_commits: u32,
|
||||||
|
/// conventional / 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)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -249,4 +429,67 @@ mod tests {
|
|||||||
assert_eq!(total, 2);
|
assert_eq!(total, 2);
|
||||||
assert_eq!(conv, 1);
|
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 忽略");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -273,6 +273,8 @@ pub fn run() {
|
|||||||
commands::agent_infra::scan_doc_freshness,
|
commands::agent_infra::scan_doc_freshness,
|
||||||
commands::agent_infra::get_doc_freshness_cache,
|
commands::agent_infra::get_doc_freshness_cache,
|
||||||
commands::commit_metrics::get_commit_stats,
|
commands::commit_metrics::get_commit_stats,
|
||||||
|
commands::commit_metrics::ingest_full_git_history,
|
||||||
|
commands::commit_metrics::get_project_commit_health,
|
||||||
// mentor
|
// mentor
|
||||||
get_mentor_context,
|
get_mentor_context,
|
||||||
get_project_notes,
|
get_project_notes,
|
||||||
|
|||||||
@ -1687,6 +1687,33 @@ export interface CommitStats {
|
|||||||
export const getCommitStats = (projectId: string) =>
|
export const getCommitStats = (projectId: string) =>
|
||||||
invoke<CommitStats>("get_commit_stats", { projectId });
|
invoke<CommitStats>("get_commit_stats", { projectId });
|
||||||
|
|
||||||
|
export interface IngestResult {
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ingestFullGitHistory = (projectId: string, projectPath: string) =>
|
||||||
|
invoke<IngestResult>("ingest_full_git_history", { projectId, projectPath });
|
||||||
|
|
||||||
|
export interface ScopeActivity {
|
||||||
|
scope: string;
|
||||||
|
commit_count: number;
|
||||||
|
rework_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommitHealth {
|
||||||
|
project_id: string;
|
||||||
|
total_commits: number;
|
||||||
|
conventional_commits: number;
|
||||||
|
conventional_rate: number;
|
||||||
|
rework_commits: number;
|
||||||
|
rework_rate: number;
|
||||||
|
top_scopes: ScopeActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProjectCommitHealth = (projectIds: string[]) =>
|
||||||
|
invoke<ProjectCommitHealth[]>("get_project_commit_health", { projectIds });
|
||||||
|
|
||||||
// ── Beast Global Status (read-only) ───────────────────────────────────────────
|
// ── Beast Global Status (read-only) ───────────────────────────────────────────
|
||||||
|
|
||||||
export interface BeastGlobalStatus {
|
export interface BeastGlobalStatus {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user