sampo_core/
enrichment.rs

1//! Enrichment module for changeset messages with commit information and author acknowledgments.
2
3use std::path::Path;
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct CommitInfo {
8    pub sha: String,
9    pub short_sha: String,
10    pub author_name: String,
11}
12
13#[derive(Debug, Clone)]
14pub struct GitHubUserInfo {
15    pub login: String,
16    pub is_first_contribution: bool,
17}
18
19/// Get the commit hash for a specific file path
20pub fn get_commit_hash_for_path(repo_root: &Path, file_path: &Path) -> Option<String> {
21    let output = Command::new("git")
22        .current_dir(repo_root)
23        .args([
24            "log",
25            "-1",
26            "--format=%H",
27            "--",
28            &file_path.to_string_lossy(),
29        ])
30        .output()
31        .ok()?;
32
33    if output.status.success() {
34        let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
35        if !hash.is_empty() { Some(hash) } else { None }
36    } else {
37        None
38    }
39}
40
41/// Detect GitHub repository slug from Git remote
42pub fn detect_github_repo_slug(repo_root: &Path) -> Option<String> {
43    detect_github_repo_slug_with_config(repo_root, None)
44}
45
46/// Detect GitHub repository slug with optional config override
47pub fn detect_github_repo_slug_with_config(
48    repo_root: &Path,
49    config_repo: Option<&str>,
50) -> Option<String> {
51    // If explicitly configured, use that
52    if let Some(repo) = config_repo {
53        return Some(repo.to_string());
54    }
55
56    // Try to extract from git remote
57    let output = Command::new("git")
58        .current_dir(repo_root)
59        .args(["remote", "get-url", "origin"])
60        .output()
61        .ok()?;
62
63    if !output.status.success() {
64        return None;
65    }
66
67    let binding = String::from_utf8_lossy(&output.stdout);
68    let url = binding.trim();
69
70    // Parse GitHub URLs (both HTTPS and SSH)
71    parse_github_url(url)
72}
73
74/// Parse GitHub repository slug from various URL formats
75fn parse_github_url(url: &str) -> Option<String> {
76    // HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo
77    if let Some(rest) = url.strip_prefix("https://github.com/") {
78        let without_git = rest.strip_suffix(".git").unwrap_or(rest);
79        if without_git.split('/').count() >= 2 {
80            return Some(without_git.to_string());
81        }
82    }
83
84    // SSH: git@github.com:owner/repo.git
85    if let Some(rest) = url.strip_prefix("git@github.com:") {
86        let without_git = rest.strip_suffix(".git").unwrap_or(rest);
87        if without_git.split('/').count() >= 2 {
88            return Some(without_git.to_string());
89        }
90    }
91
92    None
93}
94
95/// Enrich a changeset message with commit information and author acknowledgments
96pub fn enrich_changeset_message(
97    message: &str,
98    commit_hash: &str,
99    workspace: &Path,
100    repo_slug: Option<&str>,
101    github_token: Option<&str>,
102    show_commit_hash: bool,
103    show_acknowledgments: bool,
104) -> String {
105    // Create a tokio runtime for this blocking call
106    let rt = tokio::runtime::Builder::new_current_thread()
107        .enable_all()
108        .build()
109        .unwrap();
110    rt.block_on(enrich_changeset_message_async(
111        message,
112        commit_hash,
113        workspace,
114        repo_slug,
115        github_token,
116        show_commit_hash,
117        show_acknowledgments,
118    ))
119}
120
121/// Async version of enrich_changeset_message for internal use
122async fn enrich_changeset_message_async(
123    message: &str,
124    commit_hash: &str,
125    workspace: &Path,
126    repo_slug: Option<&str>,
127    github_token: Option<&str>,
128    show_commit_hash: bool,
129    show_acknowledgments: bool,
130) -> String {
131    let commit = get_commit_info_for_hash(workspace, commit_hash);
132
133    let commit_prefix = if show_commit_hash {
134        build_commit_prefix(&commit, repo_slug)
135    } else {
136        String::new()
137    };
138
139    let acknowledgment_suffix = if show_acknowledgments {
140        build_acknowledgment_suffix(&commit, repo_slug, github_token).await
141    } else {
142        String::new()
143    };
144
145    format_enriched_message(message, &commit_prefix, &acknowledgment_suffix)
146}
147
148/// Get commit information for a specific commit hash
149fn get_commit_info_for_hash(repo_root: &Path, commit_hash: &str) -> Option<CommitInfo> {
150    // Use \x1f (Unit Separator) to avoid conflicts with user content
151    let format_arg = "--format=%H\x1f%h\x1f%an";
152    let output = Command::new("git")
153        .current_dir(repo_root)
154        .args(["show", "--no-patch", format_arg, commit_hash])
155        .output()
156        .ok()?;
157
158    if !output.status.success() {
159        return None;
160    }
161
162    let stdout = String::from_utf8_lossy(&output.stdout);
163    let parts: Vec<&str> = stdout.trim().split('\x1f').collect();
164    if parts.len() != 3 {
165        return None;
166    }
167
168    Some(CommitInfo {
169        sha: parts[0].to_string(),
170        short_sha: parts[1].to_string(),
171        author_name: parts[2].to_string(),
172    })
173}
174
175/// Build commit prefix for enhanced messages
176fn build_commit_prefix(commit: &Option<CommitInfo>, repo_slug: Option<&str>) -> String {
177    if let Some(commit) = commit {
178        if let Some(slug) = repo_slug {
179            format!(
180                "[{}](https://github.com/{}/commit/{}) ",
181                commit.short_sha, slug, commit.sha
182            )
183        } else {
184            format!("{} ", commit.short_sha)
185        }
186    } else {
187        String::new()
188    }
189}
190
191/// Build acknowledgment suffix for enhanced messages
192async fn build_acknowledgment_suffix(
193    commit: &Option<CommitInfo>,
194    repo_slug: Option<&str>,
195    github_token: Option<&str>,
196) -> String {
197    let Some(commit) = commit else {
198        return String::new();
199    };
200
201    // If we have a GitHub repo and token, try to get GitHub user info
202    if let (Some(slug), Some(token)) = (repo_slug, github_token)
203        && let Some(github_user) = get_github_user_for_commit(slug, &commit.sha, token).await
204    {
205        if github_user.is_first_contribution {
206            return format!(
207                " — Thanks @{} for your first contribution 🎉!",
208                github_user.login
209            );
210        } else {
211            return format!(" — Thanks @{}!", github_user.login);
212        }
213    }
214
215    // Fallback to just the Git author name
216    format!(" — Thanks {}!", commit.author_name)
217}
218
219/// Format the final enriched message
220fn format_enriched_message(
221    message: &str,
222    commit_prefix: &str,
223    acknowledgment_suffix: &str,
224) -> String {
225    format!("{}{}{}", commit_prefix, message, acknowledgment_suffix)
226}
227
228/// Get GitHub user information for a commit
229async fn get_github_user_for_commit(
230    _repo_slug: &str,
231    _commit_sha: &str,
232    _token: &str,
233) -> Option<GitHubUserInfo> {
234    // This is a simplified version - in a real implementation you'd:
235    // 1. Get commit info from GitHub API
236    // 2. Get author's GitHub login
237    // 3. Check if it's their first contribution
238    // For now, we'll return None to avoid API calls in tests
239    None
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn parse_github_url_https() {
248        assert_eq!(
249            parse_github_url("https://github.com/owner/repo.git"),
250            Some("owner/repo".to_string())
251        );
252        assert_eq!(
253            parse_github_url("https://github.com/owner/repo"),
254            Some("owner/repo".to_string())
255        );
256    }
257
258    #[test]
259    fn parse_github_url_ssh() {
260        assert_eq!(
261            parse_github_url("git@github.com:owner/repo.git"),
262            Some("owner/repo".to_string())
263        );
264    }
265
266    #[test]
267    fn parse_github_url_invalid() {
268        assert_eq!(parse_github_url("https://gitlab.com/owner/repo.git"), None);
269        assert_eq!(parse_github_url("not-a-url"), None);
270    }
271
272    #[test]
273    fn build_commit_prefix_with_repo() {
274        let commit = Some(CommitInfo {
275            sha: "abcd1234".to_string(),
276            short_sha: "abcd".to_string(),
277            author_name: "Author".to_string(),
278        });
279
280        let prefix = build_commit_prefix(&commit, Some("owner/repo"));
281        assert_eq!(
282            prefix,
283            "[abcd](https://github.com/owner/repo/commit/abcd1234) "
284        );
285    }
286
287    #[test]
288    fn build_commit_prefix_without_repo() {
289        let commit = Some(CommitInfo {
290            sha: "abcd1234".to_string(),
291            short_sha: "abcd".to_string(),
292            author_name: "Author".to_string(),
293        });
294
295        let prefix = build_commit_prefix(&commit, None);
296        assert_eq!(prefix, "abcd ");
297    }
298
299    #[test]
300    fn format_enriched_message_complete() {
301        let message =
302            format_enriched_message("feat: add new feature", "[abcd](link) ", " — Thanks @user!");
303        assert_eq!(
304            message,
305            "[abcd](link) feat: add new feature — Thanks @user!"
306        );
307    }
308
309    #[test]
310    fn enrich_changeset_message_integration() {
311        use std::fs;
312        use tempfile::TempDir;
313
314        let temp_dir = TempDir::new().unwrap();
315        let repo_path = temp_dir.path();
316
317        // Initialize a git repo
318        std::process::Command::new("git")
319            .arg("init")
320            .current_dir(repo_path)
321            .output()
322            .unwrap();
323
324        // Configure git user
325        std::process::Command::new("git")
326            .args(["config", "user.name", "Test User"])
327            .current_dir(repo_path)
328            .output()
329            .unwrap();
330
331        std::process::Command::new("git")
332            .args(["config", "user.email", "test@example.com"])
333            .current_dir(repo_path)
334            .output()
335            .unwrap();
336
337        // Create a test file and commit it
338        let test_file = repo_path.join("test.md");
339        fs::write(&test_file, "initial content").unwrap();
340
341        std::process::Command::new("git")
342            .args(["add", "test.md"])
343            .current_dir(repo_path)
344            .output()
345            .unwrap();
346
347        std::process::Command::new("git")
348            .args(["commit", "-m", "initial commit"])
349            .current_dir(repo_path)
350            .output()
351            .unwrap();
352
353        // Get the commit hash
354        let commit_hash = get_commit_hash_for_path(repo_path, &test_file)
355            .expect("Should find commit hash for test file");
356
357        // Test enrichment with all features enabled
358        let enriched = enrich_changeset_message(
359            "fix: resolve critical bug",
360            &commit_hash,
361            repo_path,
362            Some("owner/repo"),
363            None, // no GitHub token for this test
364            true, // show commit hash
365            true, // show acknowledgments
366        );
367
368        // Should contain the commit hash link and author thanks
369        assert!(
370            enriched.contains(&commit_hash[..8]),
371            "Should contain short commit hash"
372        );
373        assert!(
374            enriched.contains("Thanks Test User!"),
375            "Should contain author thanks"
376        );
377        assert!(
378            enriched.contains("fix: resolve critical bug"),
379            "Should contain original message"
380        );
381
382        // Test with features disabled
383        let plain = enrich_changeset_message(
384            "fix: resolve critical bug",
385            &commit_hash,
386            repo_path,
387            Some("owner/repo"),
388            None,
389            false, // no commit hash
390            false, // no acknowledgments
391        );
392
393        assert_eq!(
394            plain, "fix: resolve critical bug",
395            "Should be unchanged when features disabled"
396        );
397    }
398}