diff --git a/.github/scripts/comment-pr.js b/.github/scripts/comment-pr.js new file mode 100644 index 000000000..e15750d86 --- /dev/null +++ b/.github/scripts/comment-pr.js @@ -0,0 +1,22 @@ +const { owner, repo, number } = context.issue; +const commitSha = process.env.COMMIT_SHA; +const statsOutput = process.env.STATS_OUTPUT; +const eventName = process.env.EVENT_NAME; + +let body; +let title = 'Live demo of git-ai:'; + +if (eventName === 'push') { + body = statsOutput; +} else { + body = statsOutput; +} + +const comment = title + '\n\n' + body; + +await github.rest.issues.createComment({ + owner, + repo, + issue_number: number, + body: comment +}); \ No newline at end of file diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 000000000..0a97d494e --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,60 @@ +name: PR Comment on Merge + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + issues: write + +jobs: + comment-on-merge: + name: Comment on Merged PR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Grab the refs since actions shallow clones by default + - name: Fetch AI refs + run: | + git fetch origin refs/ai/*:refs/ai/* + git fetch origin refs/ai/*:refs/remotes/origin/ai/* + + - name: Install git-ai + run: | + curl -sSL https://gitai.run/install.sh | bash + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Get git-ai stats + id: git-ai-stats + run: | + export PATH="$HOME/.local/bin:$PATH" + COMMIT_SHA="${{ github.event.pull_request.head.sha }}" + git-ai stats $COMMIT_SHA > git_ai_stats.txt + echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + - name: Comment on merged PR + uses: actions/github-script@v7 + with: + script: | + const { owner, repo, number } = context.issue; + const commitSha = '${{ steps.git-ai-stats.outputs.commit_sha }}'; + + // Read stats from file to avoid YAML escaping issues + const fs = require('fs'); + const statsOutput = fs.readFileSync('git_ai_stats.txt', 'utf8'); + + const title = '📝 **PR Commit Analysis**'; + const body = '```\n' + statsOutput + '\n```'; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: number, + body: title + '\n\n' + body + }); diff --git a/src/commands/blame.rs b/src/commands/blame.rs index 792251646..09b612d06 100644 --- a/src/commands/blame.rs +++ b/src/commands/blame.rs @@ -107,7 +107,7 @@ pub fn run( Ok(line_authors) } -fn get_git_blame_hunks( +pub fn get_git_blame_hunks( repo: &Repository, file_path: &str, start_line: u32, @@ -149,7 +149,7 @@ fn get_git_blame_hunks( Ok(hunks) } -fn overlay_ai_authorship( +pub fn overlay_ai_authorship( repo: &Repository, blame_hunks: &[BlameHunk], file_path: &str, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c4e9f79b4..734061617 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,8 +3,10 @@ pub mod checkpoint; pub mod init; pub mod post_commit; pub mod pre_commit; +pub mod stats; pub use blame::run as blame; pub use checkpoint::run as checkpoint; pub use init::run as init; pub use post_commit::run as post_commit; pub use pre_commit::run as pre_commit; +pub use stats::run as stats; diff --git a/src/commands/stats.rs b/src/commands/stats.rs new file mode 100644 index 000000000..6fa5b4f2e --- /dev/null +++ b/src/commands/stats.rs @@ -0,0 +1,169 @@ +use crate::commands::blame::{get_git_blame_hunks, overlay_ai_authorship}; +use crate::error::GitAiError; +use git2::{DiffOptions, Repository}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct FileStats { + pub additions: HashMap, + pub deletions: u32, + pub total_additions: u32, +} + +pub fn run(repo: &Repository, sha: &str) -> Result<(), GitAiError> { + // Find the commit + let commit = repo.find_commit(repo.revparse_single(sha)?.id())?; + + // Get the parent commit (for diff) + let parent = if let Ok(parent) = commit.parent(0) { + parent + } else { + return Err(GitAiError::Generic("Commit has no parent".to_string())); + }; + + // Get the diff between parent and commit + let tree = commit.tree()?; + let parent_tree = parent.tree()?; + + let mut diff_opts = DiffOptions::new(); + diff_opts.context_lines(0); // No context lines + + let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), Some(&mut diff_opts))?; + + // Process the diff and collect statistics + let mut file_stats = HashMap::new(); + let mut total_additions_by_author = HashMap::new(); + let mut total_deletions = 0; + + // For each file, collect added and deleted lines + let mut file_added_lines: HashMap> = HashMap::new(); + let mut file_deleted_counts: HashMap = HashMap::new(); + + diff.foreach( + &mut |_delta, _| true, + None, + None, + Some(&mut |delta, _hunk, line| { + let file_path = delta + .new_file() + .path() + .unwrap() + .to_string_lossy() + .to_string(); + match line.origin() { + '+' => { + let line_num = line.new_lineno().unwrap_or(0) as u32; + file_added_lines + .entry(file_path) + .or_default() + .push(line_num); + } + '-' => { + *file_deleted_counts.entry(file_path).or_default() += 1; + total_deletions += 1; + } + _ => {} + } + true + }), + )?; + + // For each file, use blame overlay logic to attribute added lines + for (file_path, added_lines) in file_added_lines.iter() { + // Get blame hunks for the file (for all lines, since we may not know the exact range) + let total_lines = { + // Try to get the file from the new tree + if let Ok(entry) = tree.get_path(std::path::Path::new(file_path)) { + if let Ok(blob) = repo.find_blob(entry.id()) { + let content = std::str::from_utf8(blob.content()).unwrap_or(""); + content.lines().count() as u32 + } else { + 0 + } + } else { + 0 + } + }; + if total_lines == 0 { + continue; + } + let blame_hunks = match get_git_blame_hunks(repo, file_path, 1, total_lines) { + Ok(hunks) => hunks, + Err(_) => continue, + }; + let line_authors = match overlay_ai_authorship(repo, &blame_hunks, file_path) { + Ok(authors) => authors, + Err(_) => continue, + }; + let stats = file_stats.entry(file_path.clone()).or_insert(FileStats { + additions: HashMap::new(), + deletions: *file_deleted_counts.get(file_path).unwrap_or(&0), + total_additions: 0, + }); + for &line_num in added_lines { + let author = line_authors + .get(&line_num) + .cloned() + .unwrap_or("unknown".to_string()); + *stats.additions.entry(author.clone()).or_insert(0) += 1; + *total_additions_by_author.entry(author).or_insert(0) += 1; + stats.total_additions += 1; + } + } + // For files with only deletions + for (file_path, &del_count) in file_deleted_counts.iter() { + file_stats.entry(file_path.clone()).or_insert(FileStats { + additions: HashMap::new(), + deletions: del_count, + total_additions: 0, + }); + } + + // Print the statistics + print_stats(&file_stats, &total_additions_by_author, total_deletions); + + Ok(()) +} + +fn print_stats( + file_stats: &HashMap, + total_additions_by_author: &HashMap, + total_deletions: u32, +) { + println!("{}", "=".repeat(50)); + + // Print per-file statistics + for (file_path, stats) in file_stats.iter() { + print_file_stats(file_path, stats); + } + + // Print totals + println!("\nTotal Additions:"); + for (author, count) in total_additions_by_author.iter() { + println!(" {} +{}", author, count); + } + + println!("\nTotal Deletions: -{}", total_deletions); +} + +fn print_file_stats(file_path: &str, stats: &FileStats) { + // Calculate total changes for the file + let total_additions = stats.total_additions; + let total_deletions = stats.deletions; + + // Print file header with total changes + println!( + "\n{} (+{} -{})", + file_path, total_additions, total_deletions + ); + + // Print additions by author + for (author, count) in stats.additions.iter() { + println!(" {} (+{})", author, count); + } + + // Print deletions (no author attribution) + if stats.deletions > 0 { + println!(" -{}", stats.deletions); + } +} diff --git a/src/main.rs b/src/main.rs index 6263dcd67..e9d1bfaa1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,11 @@ enum Commands { /// file to blame (can include line range like "file.rs:10-20") file: String, }, + /// show authorship statistics for a commit + Stats { + /// commit SHA to analyze (defaults to HEAD) + sha: Option, + }, PreCommit, PostCommit { /// force execution even if working directory is not clean @@ -102,6 +107,10 @@ fn main() { // Convert the blame result to unit result to match other commands commands::blame(&repo, &file_path, line_range).map(|_| ()) } + Commands::Stats { sha } => { + let sha = sha.as_deref().unwrap_or("HEAD"); + commands::stats(&repo, sha) + } Commands::PreCommit => commands::pre_commit(&repo, default_user_name), Commands::PostCommit { force } => { commands::post_commit(&repo, *force).unwrap();