Skip to main content

codemem_engine/enrichment/
blame.rs

1//! Blame/ownership enrichment: primary owner and contributors from git blame.
2
3use super::EnrichResult;
4use crate::CodememEngine;
5use codemem_core::{CodememError, GraphBackend, NodeKind};
6use serde_json::json;
7use std::collections::HashMap;
8
9impl CodememEngine {
10    /// Enrich file nodes with primary owner and contributors from git blame.
11    pub fn enrich_blame(
12        &self,
13        path: &str,
14        namespace: Option<&str>,
15    ) -> Result<EnrichResult, CodememError> {
16        let file_nodes: Vec<String> = {
17            let graph = self.lock_graph()?;
18            graph
19                .get_all_nodes()
20                .into_iter()
21                .filter(|n| n.kind == NodeKind::File)
22                .map(|n| n.label.clone())
23                .collect()
24        };
25
26        let mut files_annotated = 0;
27        let mut insights_stored = 0;
28
29        for file_path in &file_nodes {
30            let output = std::process::Command::new("git")
31                .args(["-C", path, "log", "--format=%an", "--", file_path])
32                .output();
33
34            let output = match output {
35                Ok(o) if o.status.success() => o,
36                _ => continue,
37            };
38
39            let stdout = String::from_utf8_lossy(&output.stdout);
40            let mut author_counts: HashMap<String, usize> = HashMap::new();
41            for line in stdout.lines() {
42                let author = line.trim();
43                if !author.is_empty() {
44                    *author_counts.entry(author.to_string()).or_default() += 1;
45                }
46            }
47
48            if author_counts.is_empty() {
49                continue;
50            }
51
52            let mut sorted_authors: Vec<_> = author_counts.into_iter().collect();
53            sorted_authors.sort_by(|a, b| b.1.cmp(&a.1));
54
55            let primary_owner = sorted_authors[0].0.clone();
56            let contributors: Vec<String> = sorted_authors.iter().map(|(a, _)| a.clone()).collect();
57
58            // Annotate graph node
59            let node_id = format!("file:{file_path}");
60            {
61                let mut graph = self.lock_graph()?;
62                if let Ok(Some(mut node)) = graph.get_node(&node_id) {
63                    node.payload
64                        .insert("primary_owner".into(), json!(primary_owner));
65                    node.payload
66                        .insert("contributors".into(), json!(contributors));
67                    let _ = graph.add_node(node);
68                    files_annotated += 1;
69                }
70            }
71        }
72
73        // Collect shared-ownership insights while holding the lock, then store outside
74        let pending_ownership: Vec<(String, String)> = {
75            let graph = self.lock_graph()?;
76            graph
77                .get_all_nodes()
78                .iter()
79                .filter(|n| n.kind == NodeKind::File)
80                .filter_map(|node| {
81                    let contribs = node.payload.get("contributors")?.as_array()?;
82                    if contribs.len() > 5 {
83                        let primary = node
84                            .payload
85                            .get("primary_owner")
86                            .and_then(|v| v.as_str())
87                            .unwrap_or("unknown");
88                        let content = format!(
89                            "Shared ownership: {} has {} contributors (primary: {}) — may need clear ownership",
90                            node.label, contribs.len(), primary
91                        );
92                        Some((content, node.id.clone()))
93                    } else {
94                        None
95                    }
96                })
97                .collect()
98        };
99
100        for (content, node_id) in &pending_ownership {
101            if self
102                .store_insight(
103                    content,
104                    "ownership",
105                    &[],
106                    0.5,
107                    namespace,
108                    std::slice::from_ref(node_id),
109                )
110                .is_some()
111            {
112                insights_stored += 1;
113            }
114        }
115
116        self.save_index();
117
118        Ok(EnrichResult {
119            insights_stored,
120            details: json!({
121                "files_annotated": files_annotated,
122                "insights_stored": insights_stored,
123            }),
124        })
125    }
126}