fix(flywheel): 修复 commit_metrics project_id 双路径冲突,表格能显示所有项目

blueprint_buffs 用 UUID 写 commit_metrics,全量导入用名称字符串写,
同一 sha 在 sha PRIMARY KEY 约束下只能存一条,导致全量导入被 INSERT OR IGNORE 全部跳过,
表格无法查到 UUID 格式的历史数据。

修复点:
- ingest_full_git_history 自动查 blueprint_buffs,若该路径已有 UUID project_id 则优先用 UUID
- 新增 get_all_commit_health 命令,SQL 关联 blueprint_buffs / projects 解析名称,不需要前端传 project_ids
- FlywheelPanel 切换到 getAllCommitHealth,表格直接用后端返回的 project_name,无需前端 lookup
This commit is contained in:
lanrtop 2026-06-29 21:21:23 +09:00
parent 78d4ad1f4b
commit d41e0f1707
4 changed files with 118 additions and 12 deletions

View File

@ -193,6 +193,7 @@ fn read_git_log(root: &std::path::Path, limit: usize) -> Result<Vec<(String, Str
/// 全量历史导入:扫描最近 500 条 commit幂等写入 commit_metrics 表。 /// 全量历史导入:扫描最近 500 条 commit幂等写入 commit_metrics 表。
/// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE /// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE
/// 若该路径在 blueprint_buffs 中已有 UUID project_id优先用 UUID保持与增量写入一致。
#[tauri::command] #[tauri::command]
pub fn ingest_full_git_history( pub fn ingest_full_git_history(
project_id: String, project_id: String,
@ -206,6 +207,16 @@ pub fn ingest_full_git_history(
let total = commits.len() as u32; let total = commits.len() as u32;
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; 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 tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut imported = 0u32; let mut imported = 0u32;
for (sha, subject, date) in &commits { for (sha, subject, date) in &commits {
@ -215,7 +226,7 @@ pub fn ingest_full_git_history(
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at) (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![ rusqlite::params![
sha, project_id, p.commit_type, p.scope, sha, effective_id, p.commit_type, p.scope,
p.is_rework as i32, p.is_ci_auto as i32, p.is_rework as i32, p.is_ci_auto as i32,
if date.is_empty() { None } else { Some(date.as_str()) }, if date.is_empty() { None } else { Some(date.as_str()) },
], ],
@ -334,6 +345,97 @@ pub fn get_project_commit_health(
project_commit_health_inner(&conn, &project_ids) 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 的指标,并通过两条路径解析名称
let mut stmt = conn.prepare(
"SELECT
cm.project_id,
COALESCE(p_direct.name, p_via_buff.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
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();
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: name,
total_commits: total,
conventional_commits: conventional,
rework_commits: rework,
top_scopes,
});
}
Ok(result)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -275,6 +275,7 @@ pub fn run() {
commands::commit_metrics::get_commit_stats, commands::commit_metrics::get_commit_stats,
commands::commit_metrics::ingest_full_git_history, commands::commit_metrics::ingest_full_git_history,
commands::commit_metrics::get_project_commit_health, commands::commit_metrics::get_project_commit_health,
commands::commit_metrics::get_all_commit_health,
// mentor // mentor
get_mentor_context, get_mentor_context,
get_project_notes, get_project_notes,

View File

@ -43,9 +43,9 @@ import {
type TemplateResult, type TemplateResult,
type CommitStats, type CommitStats,
type UserConventionsStatus, type UserConventionsStatus,
type ProjectCommitHealth, type CommitHealthWithName,
ingestFullGitHistory, ingestFullGitHistory,
getProjectCommitHealth, getAllCommitHealth,
} from "../../lib/commands"; } from "../../lib/commands";
interface Props { interface Props {
@ -1680,12 +1680,9 @@ function FlywheelPanel({ onClose }: { onClose: () => void }) {
return { projectPaths: paths, projectsMap: map, projectsWithPath: withPath }; return { projectPaths: paths, projectsMap: map, projectsWithPath: withPath };
}, [projects]); }, [projects]);
const projectIds = useMemo(() => projectsWithPath.map((p) => p.id), [projectsWithPath]); const { data: commitHealth, refetch: refetchHealth } = useQuery<CommitHealthWithName[]>({
queryKey: ["commit-health-all"],
const { data: commitHealth, refetch: refetchHealth } = useQuery<ProjectCommitHealth[]>({ queryFn: () => getAllCommitHealth(),
queryKey: ["commit-health", projectIds],
queryFn: () => getProjectCommitHealth(projectIds),
enabled: projectIds.length > 0,
}); });
const [ingesting, setIngesting] = useState(false); const [ingesting, setIngesting] = useState(false);
@ -1956,11 +1953,10 @@ function FlywheelPanel({ onClose }: { onClose: () => void }) {
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{commitHealth.map((h) => { {commitHealth.map((h) => {
const proj = projectsWithPath.find((p) => p.id === h.project_id);
return ( return (
<tr key={h.project_id} className="hover:bg-gray-50"> <tr key={h.project_id} className="hover:bg-gray-50">
<td className="py-1 text-gray-700 truncate max-w-[80px]" title={proj?.name}> <td className="py-1 text-gray-700 truncate max-w-[80px]" title={h.project_name}>
{proj?.name ?? h.project_id} {h.project_name}
</td> </td>
<td className="py-1 text-right font-mono text-gray-500">{h.total_commits}</td> <td className="py-1 text-right font-mono text-gray-500">{h.total_commits}</td>
<td className={`py-1 text-right font-mono font-semibold ${h.conventional_rate >= 0.8 ? "text-green-600" : h.conventional_rate >= 0.5 ? "text-amber-500" : "text-red-500"}`}> <td className={`py-1 text-right font-mono font-semibold ${h.conventional_rate >= 0.8 ? "text-green-600" : h.conventional_rate >= 0.5 ? "text-amber-500" : "text-red-500"}`}>

View File

@ -1714,6 +1714,13 @@ export interface ProjectCommitHealth {
export const getProjectCommitHealth = (projectIds: string[]) => export const getProjectCommitHealth = (projectIds: string[]) =>
invoke<ProjectCommitHealth[]>("get_project_commit_health", { projectIds }); invoke<ProjectCommitHealth[]>("get_project_commit_health", { projectIds });
export interface CommitHealthWithName extends ProjectCommitHealth {
project_name: string;
}
export const getAllCommitHealth = () =>
invoke<CommitHealthWithName[]>("get_all_commit_health");
// ── Beast Global Status (read-only) ─────────────────────────────────────────── // ── Beast Global Status (read-only) ───────────────────────────────────────────
export interface BeastGlobalStatus { export interface BeastGlobalStatus {