From 7095a53bf173ca6680c7888c8d1df4afb1ad29ae Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sat, 27 Sep 2025 15:10:13 -0700 Subject: add pr-analyzer A tool to analyze pull request review patterns in a GitHub repository, focusing on team members' review behaviors and habits. This tool helps teams understand and improve their code review processes by providing insights into review frequency, speed, thoroughness, and approval patterns. --- cmd/pr-analyzer/main.go | 184 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 cmd/pr-analyzer/main.go (limited to 'cmd/pr-analyzer/main.go') diff --git a/cmd/pr-analyzer/main.go b/cmd/pr-analyzer/main.go new file mode 100644 index 0000000..b12be31 --- /dev/null +++ b/cmd/pr-analyzer/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "go.fcuny.net/x/internal/github" +) + +type Config struct { + Token string + Repo string + StartDate time.Time + EndDate time.Time + APIURL string +} + +func parseFlags() (*Config, error) { + config := &Config{} + + flag.StringVar(&config.Token, "token", "", "GitHub personal access token (required)") + flag.StringVar(&config.Repo, "repo", "", "Repository in owner/repo format (required)") + + startDateStr := flag.String( + "start", + time.Now().AddDate(0, -1, 0).Format("2006-01-02"), + "Start date (YYYY-MM-DD)", + ) + endDateStr := flag.String("end", time.Now().Format("2006-01-02"), "End date (YYYY-MM-DD)") + flag.StringVar( + &config.APIURL, + "api-url", + "https://api.github.com", + "GitHub API URL (for GitHub Enterprise)", + ) + + flag.Parse() + + if config.Token == "" { + return nil, fmt.Errorf("--token is required") + } + + if config.Repo == "" { + return nil, fmt.Errorf("--repo is required") + } + + startDate, err := time.Parse("2006-01-02", *startDateStr) + if err != nil { + return nil, fmt.Errorf("invalid start date format: %v", err) + } + config.StartDate = startDate + + endDate, err := time.Parse("2006-01-02", *endDateStr) + if err != nil { + return nil, fmt.Errorf("invalid end date format: %v", err) + } + config.EndDate = endDate + + return config, nil +} + +func convertToAnalyzerTypes( + prs []github.PullRequest, + reviews map[int][]github.Review, + comments map[int][]github.ReviewComment, +) []PRData { + var prData []PRData + + for _, pr := range prs { + prReviews := reviews[pr.Number] + if prReviews == nil { + prReviews = []github.Review{} + } + + prComments := comments[pr.Number] + if prComments == nil { + prComments = []github.ReviewComment{} + } + + commentsByReview := make(map[int64][]CommentData) + for _, comment := range prComments { + commentsByReview[comment.ReviewID] = append( + commentsByReview[comment.ReviewID], + CommentData{ + Reviewer: comment.User.Login, + CreatedAt: comment.CreatedAt, + Path: comment.Path, + Line: comment.Line, + }, + ) + } + + var reviewData []ReviewData + for _, review := range prReviews { + reviewData = append(reviewData, ReviewData{ + Reviewer: review.User.Login, + State: review.State, + SubmittedAt: review.SubmittedAt, + Comments: commentsByReview[review.ID], + }) + } + + prData = append(prData, PRData{ + Number: pr.Number, + CreatedAt: pr.CreatedAt, + Reviews: reviewData, + }) + } + + return prData +} + +func runAnalysis(ctx context.Context, config *Config) error { + client := github.NewClient(config.Token, config.APIURL) + analyzer := NewAnalyzer(config.StartDate, config.EndDate) + formatter := NewFormatter(config.Repo, config.StartDate, config.EndDate) + + parts := strings.Split(config.Repo, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s (expected owner/repo)", config.Repo) + } + owner, repo := parts[0], parts[1] + + fmt.Print(formatter.FormatProgress("Fetching pull requests...")) + prs, err := client.GetPullRequests(ctx, owner, repo, config.StartDate, config.EndDate) + if err != nil { + return fmt.Errorf("fetching pull requests: %w", err) + } + + reviews := make(map[int][]github.Review) + comments := make(map[int][]github.ReviewComment) + + for i, pr := range prs { + fmt.Print( + formatter.FormatProgress( + fmt.Sprintf("Processing PR %d/%d: #%d", i+1, len(prs), pr.Number), + ), + ) + + prReviews, err := client.GetPullRequestReviews(ctx, owner, repo, pr.Number) + if err != nil { + return fmt.Errorf("fetching reviews for PR #%d: %w", pr.Number, err) + } + reviews[pr.Number] = prReviews + + prComments, err := client.GetPullRequestReviewComments(ctx, owner, repo, pr.Number) + if err != nil { + return fmt.Errorf("fetching comments for PR #%d: %w", pr.Number, err) + } + comments[pr.Number] = prComments + } + + prData := convertToAnalyzerTypes(prs, reviews, comments) + + fmt.Print(formatter.FormatProgress("Analyzing PR data...")) + metrics, err := analyzer.AnalyzePRs(prData) + if err != nil { + return fmt.Errorf("analyzing PRs: %w", err) + } + + fmt.Print(formatter.FormatTable(metrics)) + return nil +} + +func main() { + config, err := parseFlags() + if err != nil { + log.Printf("Error: %v\n", err) + flag.Usage() + os.Exit(1) + } + + ctx := context.Background() + + if err := runAnalysis(ctx, config); err != nil { + log.Printf("Error: %v\n", err) + os.Exit(1) + } +} -- cgit v1.2.3