aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--cmd/pr-analyzer/README.md96
-rw-r--r--cmd/pr-analyzer/analyzer.go122
-rw-r--r--cmd/pr-analyzer/analyzer_test.go233
-rw-r--r--cmd/pr-analyzer/formatter.go78
-rw-r--r--cmd/pr-analyzer/main.go184
-rw-r--r--cmd/pr-analyzer/types.go105
6 files changed, 818 insertions, 0 deletions
diff --git a/cmd/pr-analyzer/README.md b/cmd/pr-analyzer/README.md
new file mode 100644
index 0000000..e2c56a5
--- /dev/null
+++ b/cmd/pr-analyzer/README.md
@@ -0,0 +1,96 @@
+# GitHub PR Review 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.
+
+## Usage
+
+### Basic Usage
+
+```bash
+# Basic usage with default date range (last month)
+pr-analyze --token=your_github_token --repo=owner/repo
+
+# Specify custom date range
+pr-analyze --token=your_github_token --repo=owner/repo --start=2024-01-01 --end=2024-03-31
+
+# Use with GitHub Enterprise
+# Option 1: Include /api/v3 in the URL
+pr-analyze --token=your_github_token --repo=owner/repo --api-url=https://github.company.com/api/v3
+
+# Option 2: Let the tool add /api/v3 automatically
+pr-analyze --token=your_github_token --repo=owner/repo --api-url=https://github.company.com
+```
+
+### Required Flags
+
+- `--token`: Your GitHub personal access token (required)
+- `--repo`: Repository in owner/repo format (required)
+
+### Optional Flags
+
+- `--start`: Start date in YYYY-MM-DD format (default: 1 month ago)
+- `--end`: End date in YYYY-MM-DD format (default: today)
+- `--api-url`: GitHub API URL (default: https://api.github.com)
+ - For GitHub Enterprise, use your instance URL (e.g., https://github.company.com)
+ - The tool will automatically add /api/v3 if needed
+
+## GitHub Enterprise Support
+
+When using this tool with GitHub Enterprise:
+
+1. Make sure you have the correct permissions:
+
+ - `repo` scope for private repositories
+ - `read:org` scope for organization repositories
+
+1. The API URL can be specified in two ways:
+
+ - Full URL with /api/v3: `https://github.company.com/api/v3`
+ - Base URL: `https://github.company.com` (the tool will add /api/v3)
+
+1. Common issues and solutions:
+
+ - "Not Found" error: Check that the repository path is correct and you have access
+ - "Unauthorized" error: Verify your token has the correct permissions
+ - "Rate limit exceeded": Wait for the rate limit to reset or use a token with higher limits
+
+## Metrics Explained
+
+The tool analyzes and presents the following metrics for each team member:
+
+1. **PRs Reviewed**
+
+ - Total number of pull requests reviewed by the team member
+ - Helps identify active reviewers and potential bottlenecks
+
+1. **Average Review Speed**
+
+ - Time from PR creation to first comment
+ - Measured in hours
+ - Lower values indicate faster initial feedback
+
+1. **Average Comments per File**
+
+ - Number of inline comments made per file when reviewing
+ - Higher values suggest more thorough reviews
+ - Only counted when comments are present
+
+1. **Immediate Approvals**
+
+ - Percentage of PRs approved without requesting changes
+ - Helps understand review patterns and potential quality trade-offs
+
+## Output Format
+
+The tool generates a table showing metrics for each team member:
+
+```
+GitHub PR Review Analysis for owner/repo (Jan 1, 2024 - Mar 31, 2024)
+-----------------------------------------------------------------------
+Team Member | PRs Reviewed | Avg Review Speed | Avg Comments/File | Immediate Approvals
+-----------------------------------------------------------------------
+dev1 | 24 | 4.2 hours | 1.8 | 25%
+dev2 | 18 | 2.1 hours | 0.9 | 78%
+dev3 | 32 | 8.5 hours | 3.2 | 12%
+-----------------------------------------------------------------------
+```
diff --git a/cmd/pr-analyzer/analyzer.go b/cmd/pr-analyzer/analyzer.go
new file mode 100644
index 0000000..a793509
--- /dev/null
+++ b/cmd/pr-analyzer/analyzer.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "fmt"
+ "sort"
+ "time"
+)
+
+// AnalyzePRs processes a list of PRs and generates metrics for each reviewer.
+func (a *Analyzer) AnalyzePRs(prs []PRData) ([]ReviewerMetrics, error) {
+ reviewerPRs := make(map[string][]PRMetrics)
+
+ for _, pr := range prs {
+ metrics, err := a.analyzePR(pr)
+ if err != nil {
+ return nil, fmt.Errorf("analyzing PR %d: %w", pr.Number, err)
+ }
+
+ for _, reviewer := range metrics.Reviewers {
+ reviewerPRs[reviewer] = append(reviewerPRs[reviewer], metrics)
+ }
+ }
+
+ var results []ReviewerMetrics
+ for reviewer, prs := range reviewerPRs {
+ metrics := a.calculateReviewerMetrics(reviewer, prs)
+ results = append(results, metrics)
+ }
+
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].PRsReviewed > results[j].PRsReviewed
+ })
+
+ return results, nil
+}
+
+func (a *Analyzer) analyzePR(pr PRData) (PRMetrics, error) {
+ metrics := PRMetrics{
+ Number: pr.Number,
+ CreatedAt: pr.CreatedAt,
+ CommentsPerFile: make(map[string]int),
+ Reviewers: make([]string, 0),
+ }
+
+ sort.Slice(pr.Reviews, func(i, j int) bool {
+ return pr.Reviews[i].SubmittedAt.Before(pr.Reviews[j].SubmittedAt)
+ })
+
+ for _, review := range pr.Reviews {
+ metrics.Reviewers = append(metrics.Reviewers, review.Reviewer)
+
+ pattern := ReviewPattern{
+ FirstReviewState: review.State,
+ TimeToFirstReview: review.SubmittedAt.Sub(pr.CreatedAt),
+ }
+
+ for _, comment := range review.Comments {
+ metrics.CommentsPerFile[comment.Path]++
+ }
+
+ metrics.ReviewPatterns = append(metrics.ReviewPatterns, pattern)
+ }
+
+ if len(pr.Reviews) > 0 {
+ metrics.TimeToFirstReview = pr.Reviews[0].SubmittedAt.Sub(pr.CreatedAt)
+ }
+
+ if len(metrics.ReviewPatterns) > 0 {
+ lastReview := pr.Reviews[len(pr.Reviews)-1]
+ metrics.ReviewPatterns[len(metrics.ReviewPatterns)-1].FinalReviewState = lastReview.State
+ metrics.ReviewPatterns[len(metrics.ReviewPatterns)-1].TimeToFinalReview = lastReview.SubmittedAt.Sub(
+ pr.CreatedAt,
+ )
+ }
+
+ return metrics, nil
+}
+
+func (a *Analyzer) calculateReviewerMetrics(reviewer string, prs []PRMetrics) ReviewerMetrics {
+ metrics := ReviewerMetrics{
+ Username: reviewer,
+ PRsReviewed: len(prs),
+ }
+
+ var totalReviewSpeed time.Duration
+ var totalComments int
+ var immediateApprovals int
+
+ for _, pr := range prs {
+ var pattern ReviewPattern
+ for _, p := range pr.ReviewPatterns {
+ if p.FirstReviewState != "" {
+ pattern = p
+ break
+ }
+ }
+
+ if pattern.TimeToFirstReview > 0 {
+ totalReviewSpeed += pattern.TimeToFirstReview
+ }
+
+ for _, count := range pr.CommentsPerFile {
+ totalComments += count
+ }
+
+ if pattern.FirstReviewState == "APPROVED" {
+ immediateApprovals++
+ }
+ }
+
+ if metrics.PRsReviewed > 0 {
+ metrics.AverageReviewSpeed = totalReviewSpeed.Hours() / float64(metrics.PRsReviewed)
+ metrics.AverageComments = float64(totalComments) / float64(metrics.PRsReviewed)
+ metrics.ImmediateApprovals = float64(
+ immediateApprovals,
+ ) / float64(
+ metrics.PRsReviewed,
+ ) * 100
+ }
+
+ return metrics
+}
diff --git a/cmd/pr-analyzer/analyzer_test.go b/cmd/pr-analyzer/analyzer_test.go
new file mode 100644
index 0000000..d2c3041
--- /dev/null
+++ b/cmd/pr-analyzer/analyzer_test.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+ "testing"
+ "time"
+
+ "go.fcuny.net/x/internal/github"
+)
+
+func TestAnalyzePRs(t *testing.T) {
+ startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ endDate := time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC)
+ a := NewAnalyzer(startDate, endDate)
+
+ tests := []struct {
+ name string
+ prs []github.PullRequest
+ reviews map[int][]github.Review
+ comments map[int][]github.ReviewComment
+ want []ReviewerMetrics
+ }{
+ {
+ name: "single PR with immediate approval",
+ prs: []github.PullRequest{
+ {
+ Number: 1,
+ CreatedAt: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
+ User: github.User{
+ Login: "author1",
+ },
+ },
+ },
+ reviews: map[int][]github.Review{
+ 1: {
+ {
+ ID: 1,
+ User: github.User{Login: "reviewer1"},
+ State: "APPROVED",
+ SubmittedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
+ },
+ },
+ },
+ comments: map[int][]github.ReviewComment{},
+ want: []ReviewerMetrics{
+ {
+ Username: "reviewer1",
+ PRsReviewed: 1,
+ AverageReviewSpeed: 0.5, // 30 minutes
+ AverageComments: 0,
+ ImmediateApprovals: 100.0, // 100% immediate approvals
+ },
+ },
+ },
+ {
+ name: "PR with comments and changes requested",
+ prs: []github.PullRequest{
+ {
+ Number: 2,
+ CreatedAt: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
+ User: github.User{
+ Login: "author2",
+ },
+ },
+ },
+ reviews: map[int][]github.Review{
+ 2: {
+ {
+ ID: 2,
+ User: github.User{Login: "reviewer2"},
+ State: "CHANGES_REQUESTED",
+ SubmittedAt: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
+ },
+ },
+ },
+ comments: map[int][]github.ReviewComment{
+ 2: {
+ {
+ ID: 3,
+ ReviewID: 2,
+ User: github.User{Login: "reviewer2"},
+ CreatedAt: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
+ Path: "file1.go",
+ Line: 10,
+ },
+ {
+ ID: 4,
+ ReviewID: 2,
+ User: github.User{Login: "reviewer2"},
+ CreatedAt: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
+ Path: "file2.go",
+ Line: 20,
+ },
+ },
+ },
+ want: []ReviewerMetrics{
+ {
+ Username: "reviewer2",
+ PRsReviewed: 1,
+ AverageReviewSpeed: 1.0, // 1 hour
+ AverageComments: 2.0,
+ ImmediateApprovals: 0.0, // 0% immediate approvals
+ },
+ },
+ },
+ {
+ name: "multiple PRs with same reviewer",
+ prs: []github.PullRequest{
+ {
+ Number: 3,
+ CreatedAt: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC),
+ User: github.User{
+ Login: "author3",
+ },
+ },
+ {
+ Number: 4,
+ CreatedAt: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC),
+ User: github.User{
+ Login: "author4",
+ },
+ },
+ },
+ reviews: map[int][]github.Review{
+ 3: {
+ {
+ ID: 5,
+ User: github.User{Login: "reviewer3"},
+ State: "APPROVED",
+ SubmittedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
+ },
+ },
+ 4: {
+ {
+ ID: 6,
+ User: github.User{Login: "reviewer3"},
+ State: "APPROVED",
+ SubmittedAt: time.Date(2024, 1, 15, 11, 30, 0, 0, time.UTC),
+ },
+ },
+ },
+ comments: map[int][]github.ReviewComment{},
+ want: []ReviewerMetrics{
+ {
+ Username: "reviewer3",
+ PRsReviewed: 2,
+ AverageReviewSpeed: 0.5, // 30 minutes average
+ AverageComments: 0,
+ ImmediateApprovals: 100.0, // 100% immediate approvals
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prData := make([]PRData, len(tt.prs))
+ for i, pr := range tt.prs {
+ reviews := tt.reviews[pr.Number]
+ comments := tt.comments[pr.Number]
+
+ commentsByReview := make(map[int64][]CommentData)
+ for _, comment := range comments {
+ commentsByReview[comment.ReviewID] = append(
+ commentsByReview[comment.ReviewID],
+ CommentData{
+ Reviewer: comment.User.Login,
+ CreatedAt: comment.CreatedAt,
+ Path: comment.Path,
+ Line: comment.Line,
+ },
+ )
+ }
+
+ reviewData := make([]ReviewData, len(reviews))
+ for j, review := range reviews {
+ reviewData[j] = ReviewData{
+ Reviewer: review.User.Login,
+ State: review.State,
+ SubmittedAt: review.SubmittedAt,
+ Comments: commentsByReview[review.ID],
+ }
+ }
+
+ prData[i] = PRData{
+ Number: pr.Number,
+ CreatedAt: pr.CreatedAt,
+ Reviews: reviewData,
+ }
+ }
+
+ got, err := a.AnalyzePRs(prData)
+ if err != nil {
+ t.Errorf("AnalyzePRs() error = %v", err)
+ return
+ }
+
+ if len(got) != len(tt.want) {
+ t.Errorf("AnalyzePRs() returned %d metrics, want %d", len(got), len(tt.want))
+ return
+ }
+
+ for i, m := range got {
+ if m.Username != tt.want[i].Username {
+ t.Errorf("Username = %v, want %v", m.Username, tt.want[i].Username)
+ }
+ if m.PRsReviewed != tt.want[i].PRsReviewed {
+ t.Errorf("PRsReviewed = %v, want %v", m.PRsReviewed, tt.want[i].PRsReviewed)
+ }
+ if m.AverageReviewSpeed != tt.want[i].AverageReviewSpeed {
+ t.Errorf(
+ "AverageReviewSpeed = %v, want %v",
+ m.AverageReviewSpeed,
+ tt.want[i].AverageReviewSpeed,
+ )
+ }
+ if m.AverageComments != tt.want[i].AverageComments {
+ t.Errorf(
+ "AverageComments = %v, want %v",
+ m.AverageComments,
+ tt.want[i].AverageComments,
+ )
+ }
+ if m.ImmediateApprovals != tt.want[i].ImmediateApprovals {
+ t.Errorf(
+ "ImmediateApprovals = %v, want %v",
+ m.ImmediateApprovals,
+ tt.want[i].ImmediateApprovals,
+ )
+ }
+ }
+ })
+ }
+}
diff --git a/cmd/pr-analyzer/formatter.go b/cmd/pr-analyzer/formatter.go
new file mode 100644
index 0000000..a9e0f03
--- /dev/null
+++ b/cmd/pr-analyzer/formatter.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "text/tabwriter"
+ "time"
+)
+
+// Formatter handles the formatting of analysis results
+type Formatter struct {
+ repo string
+ startDate time.Time
+ endDate time.Time
+}
+
+// NewFormatter creates a new output formatter
+func NewFormatter(repo string, startDate, endDate time.Time) *Formatter {
+ return &Formatter{
+ repo: repo,
+ startDate: startDate,
+ endDate: endDate,
+ }
+}
+
+// FormatTable generates a formatted table string from the analysis results
+func (f *Formatter) FormatTable(metrics []ReviewerMetrics) string {
+ var sb strings.Builder
+ w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', tabwriter.TabIndent)
+
+ _, err := fmt.Fprintf(w, "GitHub PR Review Analysis for %s (%s - %s)\n",
+ f.repo,
+ f.startDate.Format("Jan 2, 2006"),
+ f.endDate.Format("Jan 2, 2006"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ _, err = fmt.Fprintln(w, strings.Repeat("-", 80))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ _, err = fmt.Fprintln(
+ w,
+ "Team Member\tPRs Reviewed\tAvg Review Speed\tAvg Comments/File\tImmediate Approvals",
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, m := range metrics {
+ _, err = fmt.Fprintf(w, "%s\t%d\t%.1f hours\t%.1f\t%.1f%%\n",
+ m.Username,
+ m.PRsReviewed,
+ m.AverageReviewSpeed,
+ m.AverageComments,
+ m.ImmediateApprovals)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ if err := w.Flush(); err != nil {
+ log.Fatal(err)
+ }
+ return sb.String()
+}
+
+// FormatError generates a formatted error message
+func (f *Formatter) FormatError(err error) string {
+ return fmt.Sprintf("Error: %v\n", err)
+}
+
+// FormatProgress generates a formatted progress message
+func (f *Formatter) FormatProgress(msg string) string {
+ return fmt.Sprintf("Progress: %s\n", msg)
+}
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)
+ }
+}
diff --git a/cmd/pr-analyzer/types.go b/cmd/pr-analyzer/types.go
new file mode 100644
index 0000000..4c3a83d
--- /dev/null
+++ b/cmd/pr-analyzer/types.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "time"
+)
+
+// PRData represents a pull request with its reviews and comments.
+type PRData struct {
+ // Number is the pull request number
+ Number int
+ // CreatedAt is the timestamp when the PR was created
+ CreatedAt time.Time
+ // Reviews contains all reviews submitted for this PR
+ Reviews []ReviewData
+}
+
+// ReviewData represents a review submitted on a pull request.
+type ReviewData struct {
+ // Reviewer is the GitHub username of the person who submitted the review
+ Reviewer string
+ // State represents the review state: APPROVED, CHANGES_REQUESTED, or COMMENTED
+ State string
+ // SubmittedAt is the timestamp when the review was submitted
+ SubmittedAt time.Time
+ // Comments contains all inline comments made during the review
+ Comments []CommentData
+}
+
+// CommentData represents an inline comment made during a review.
+type CommentData struct {
+ // Reviewer is the GitHub username of the person who made the comment
+ Reviewer string
+ // CreatedAt is the timestamp when the comment was created
+ CreatedAt time.Time
+ // Path is the file path where the comment was made
+ Path string
+ // Line is the line number where the comment was made
+ Line int
+}
+
+// ReviewerMetrics holds the analysis results for a single reviewer.
+type ReviewerMetrics struct {
+ // Username is the GitHub username of the reviewer
+ Username string
+ // PRsReviewed is the total number of PRs reviewed by this person
+ PRsReviewed int
+ // AverageReviewSpeed is the average time (in hours) from PR creation
+ // to the reviewer's first comment
+ AverageReviewSpeed float64 // in hours
+ // AverageComments is the average number of inline comments made
+ // per PR when the reviewer comments
+ AverageComments float64
+ // ImmediateApprovals is the percentage of PRs that were approved
+ // without requesting changes first
+ ImmediateApprovals float64 // percentage
+}
+
+// ReviewPattern represents the approval pattern for a PR.
+type ReviewPattern struct {
+ // IsImmediateApproval indicates if the PR was approved without
+ // requesting changes first
+ IsImmediateApproval bool
+ // FirstReviewState is the state of the first review submitted
+ FirstReviewState string
+ // FinalReviewState is the state of the last review submitted
+ FinalReviewState string
+ // TimeToFirstReview is the duration from PR creation to first review
+ TimeToFirstReview time.Duration
+ // TimeToFinalReview is the duration from PR creation to final review
+ TimeToFinalReview time.Duration
+}
+
+// PRMetrics holds all metrics for a single PR.
+type PRMetrics struct {
+ // Number is the pull request number
+ Number int
+ // CreatedAt is the timestamp when the PR was created
+ CreatedAt time.Time
+ // ReviewPatterns contains the review patterns for each reviewer
+ ReviewPatterns []ReviewPattern
+ // CommentsPerFile maps file paths to the number of comments made
+ // on that file
+ CommentsPerFile map[string]int
+ // Reviewers is the list of GitHub usernames who reviewed this PR
+ Reviewers []string
+ // TimeToFirstReview is the duration from PR creation to the first
+ // review being submitted
+ TimeToFirstReview time.Duration
+}
+
+// Analyzer processes PR data and generates metrics.
+type Analyzer struct {
+ // startDate is the beginning of the analysis period
+ startDate time.Time
+ // endDate is the end of the analysis period
+ endDate time.Time
+}
+
+// NewAnalyzer creates a new PR analyzer with the specified date range.
+func NewAnalyzer(startDate, endDate time.Time) *Analyzer {
+ return &Analyzer{
+ startDate: startDate,
+ endDate: endDate,
+ }
+}