aboutsummaryrefslogtreecommitdiff
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
-rw-r--r--internal/github/client.go345
-rw-r--r--internal/github/types.go119
8 files changed, 1282 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,
+ }
+}
diff --git a/internal/github/client.go b/internal/github/client.go
new file mode 100644
index 0000000..c864a8c
--- /dev/null
+++ b/internal/github/client.go
@@ -0,0 +1,345 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ maxRetries = 3
+ retryDelay = 2 * time.Second
+)
+
+// Client implements the GitHub API client.
+type Client struct {
+ httpClient *http.Client
+ token string
+ apiURL string
+}
+
+// NewClient creates a new GitHub API client.
+func NewClient(token, apiURL string) *Client {
+ apiURL = strings.TrimRight(apiURL, "/")
+ if !strings.HasSuffix(apiURL, "/api/v3") {
+ apiURL = strings.TrimRight(apiURL, "/api")
+ apiURL = apiURL + "/api/v3"
+ }
+
+ return &Client{
+ httpClient: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ token: token,
+ apiURL: apiURL,
+ }
+}
+
+// GetPullRequests fetches pull requests created within the specified date range.
+func (c *Client) GetPullRequests(
+ ctx context.Context,
+ owner, repo string,
+ since, until time.Time,
+) ([]PullRequest, error) {
+ url := fmt.Sprintf("%s/repos/%s/%s/pulls", c.apiURL, owner, repo)
+
+ params := []string{
+ "state=all",
+ "sort=created",
+ "direction=desc",
+ "per_page=100", // Maximum allowed by GitHub
+ }
+
+ log.Printf("Fetching PRs from: %s with params: %v", url, params)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url+"?"+strings.Join(params, "&"), nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ var allPRs []PullRequest
+ for {
+ prs, nextPage, err := c.doGetPullRequests(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetching PRs: %w", err)
+ }
+ allPRs = append(allPRs, prs...)
+
+ // Check if we've reached PRs outside our date range
+ if len(prs) > 0 && prs[len(prs)-1].CreatedAt.Before(since) {
+ break
+ }
+
+ if nextPage == "" {
+ break
+ }
+
+ req.URL.RawQuery = nextPage
+ }
+
+ var filteredPRs []PullRequest
+ for _, pr := range allPRs {
+ if pr.CreatedAt.After(since) && pr.CreatedAt.Before(until) {
+ filteredPRs = append(filteredPRs, pr)
+ }
+ }
+
+ log.Printf("Found %d PRs in date range", len(filteredPRs))
+ return filteredPRs, nil
+}
+
+// GetPullRequestReviews fetches all reviews for a specific pull request.
+func (c *Client) GetPullRequestReviews(
+ ctx context.Context,
+ owner, repo string,
+ prNumber int,
+) ([]Review, error) {
+ url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.apiURL, owner, repo, prNumber)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ var reviews []Review
+ err = c.doRequest(req, &reviews)
+ if err != nil {
+ return nil, err
+ }
+
+ return reviews, nil
+}
+
+// GetPullRequestReviewComments fetches all review comments for a specific pull request.
+func (c *Client) GetPullRequestReviewComments(
+ ctx context.Context,
+ owner, repo string,
+ prNumber int,
+) ([]ReviewComment, error) {
+ url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.apiURL, owner, repo, prNumber)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ var reviews []Review
+ err = c.doRequest(req, &reviews)
+ if err != nil {
+ return nil, err
+ }
+
+ var allComments []ReviewComment
+ for _, review := range reviews {
+ comments, err := c.getReviewComments(ctx, owner, repo, prNumber, review.ID)
+ if err != nil {
+ return nil, err
+ }
+ allComments = append(allComments, comments...)
+ }
+
+ return allComments, nil
+}
+
+// GetRateLimit returns the current rate limit information.
+func (c *Client) GetRateLimit(ctx context.Context) (*RateLimit, error) {
+ url := fmt.Sprintf("%s/rate_limit", c.apiURL)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ var response struct {
+ Resources struct {
+ Core struct {
+ Limit int `json:"limit"`
+ Remaining int `json:"remaining"`
+ Reset time.Time `json:"reset"`
+ } `json:"core"`
+ } `json:"resources"`
+ }
+
+ err = c.doRequest(req, &response)
+ if err != nil {
+ return nil, err
+ }
+
+ return &RateLimit{
+ Limit: response.Resources.Core.Limit,
+ Remaining: response.Resources.Core.Remaining,
+ Reset: response.Resources.Core.Reset,
+ }, nil
+}
+
+func (c *Client) doGetPullRequests(req *http.Request) ([]PullRequest, string, error) {
+ var prs []PullRequest
+ nextPage, err := c.doRequestWithPagination(req, &prs)
+ if err != nil {
+ return nil, "", err
+ }
+ return prs, nextPage, nil
+}
+
+func (c *Client) getReviewComments(
+ ctx context.Context,
+ owner, repo string,
+ prNumber int,
+ reviewID int64,
+) ([]ReviewComment, error) {
+ url := fmt.Sprintf(
+ "%s/repos/%s/%s/pulls/%d/reviews/%d/comments",
+ c.apiURL,
+ owner,
+ repo,
+ prNumber,
+ reviewID,
+ )
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ var comments []ReviewComment
+ err = c.doRequest(req, &comments)
+ if err != nil {
+ return nil, err
+ }
+
+ return comments, nil
+}
+
+func (c *Client) doRequest(req *http.Request, v any) error {
+ req.Header.Set("Authorization", "token "+c.token)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ var lastErr error
+ for i := range maxRetries {
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ lastErr = err
+ log.Printf("Request failed (attempt %d/%d): %v", i+1, maxRetries, err)
+ time.Sleep(retryDelay * time.Duration(i+1))
+ continue
+ }
+ defer func() {
+ err := resp.Body.Close()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }()
+
+ // Check rate limit
+ if resp.StatusCode == http.StatusForbidden {
+ resetTime := resp.Header.Get("X-RateLimit-Reset")
+ if resetTime != "" {
+ reset, err := strconv.ParseInt(resetTime, 10, 64)
+ if err == nil {
+ waitTime := time.Until(time.Unix(reset, 0))
+ if waitTime > 0 {
+ log.Printf("Rate limit exceeded. Waiting %v before retry", waitTime)
+ time.Sleep(waitTime)
+ continue
+ }
+ }
+ }
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ var apiErr APIError
+ if err := json.Unmarshal(body, &apiErr); err == nil {
+ log.Printf("API Error Response: %+v", apiErr)
+ return fmt.Errorf("API error: %s (Status: %d)", apiErr.Message, resp.StatusCode)
+ }
+ log.Printf("Unexpected response body: %s", string(body))
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+
+ return nil
+ }
+
+ return fmt.Errorf("max retries exceeded: %w", lastErr)
+}
+
+func (c *Client) doRequestWithPagination(req *http.Request, v any) (string, error) {
+ req.Header.Set("Authorization", "token "+c.token)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ var lastErr error
+ for i := range maxRetries {
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ lastErr = err
+ log.Printf("Request failed (attempt %d/%d): %v", i+1, maxRetries, err)
+ time.Sleep(retryDelay * time.Duration(i+1))
+ continue
+ }
+ defer func() {
+ err := resp.Body.Close()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }()
+
+ if resp.StatusCode == http.StatusForbidden {
+ resetTime := resp.Header.Get("X-RateLimit-Reset")
+ if resetTime != "" {
+ reset, err := strconv.ParseInt(resetTime, 10, 64)
+ if err == nil {
+ waitTime := time.Until(time.Unix(reset, 0))
+ if waitTime > 0 {
+ log.Printf("Rate limit exceeded. Waiting %v before retry", waitTime)
+ time.Sleep(waitTime)
+ continue
+ }
+ }
+ }
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ var apiErr APIError
+ if err := json.Unmarshal(body, &apiErr); err == nil {
+ log.Printf("API Error Response: %+v", apiErr)
+ return "", fmt.Errorf("API error: %s (Status: %d)", apiErr.Message, resp.StatusCode)
+ }
+ log.Printf("Unexpected response body: %s", string(body))
+ return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+ return "", fmt.Errorf("decoding response: %w", err)
+ }
+
+ nextPage := ""
+ if link := resp.Header.Get("Link"); link != "" {
+ parts := strings.SplitSeq(link, ",")
+ for part := range parts {
+ if strings.Contains(part, "rel=\"next\"") {
+ start := strings.Index(part, "<")
+ end := strings.Index(part, ">")
+ if start >= 0 && end > start {
+ nextPage = part[start+1 : end]
+ break
+ }
+ }
+ }
+ }
+
+ return nextPage, nil
+ }
+
+ return "", fmt.Errorf("max retries exceeded: %w", lastErr)
+}
diff --git a/internal/github/types.go b/internal/github/types.go
new file mode 100644
index 0000000..c72ee5f
--- /dev/null
+++ b/internal/github/types.go
@@ -0,0 +1,119 @@
+package github
+
+import (
+ "context"
+ "time"
+)
+
+// PullRequest represents a GitHub pull request.
+type PullRequest struct {
+ // Number is the pull request number
+ Number int `json:"number"`
+ // Title is the pull request title
+ Title string `json:"title"`
+ // CreatedAt is the timestamp when the PR was created
+ CreatedAt time.Time `json:"created_at"`
+ // UpdatedAt is the timestamp when the PR was last updated
+ UpdatedAt time.Time `json:"updated_at"`
+ // User is the GitHub user who created the PR
+ User User `json:"user"`
+}
+
+// Review represents a pull request review.
+type Review struct {
+ // ID is the unique identifier for the review
+ ID int64 `json:"id"`
+ // User is the GitHub user who submitted the review
+ User User `json:"user"`
+ // State represents the review decision: APPROVED, CHANGES_REQUESTED, or COMMENTED
+ State string `json:"state"`
+ // SubmittedAt is the timestamp when the review was submitted
+ SubmittedAt time.Time `json:"submitted_at"`
+ // Body is the review comment text
+ Body string `json:"body"`
+}
+
+// ReviewComment represents an inline comment on a pull request review.
+type ReviewComment struct {
+ // ID is the unique identifier for the comment
+ ID int64 `json:"id"`
+ // ReviewID is the ID of the review this comment belongs to
+ ReviewID int64 `json:"pull_request_review_id"`
+ // User is the GitHub user who made the comment
+ User User `json:"user"`
+ // Body is the comment text
+ Body string `json:"body"`
+ // CreatedAt is the timestamp when the comment was created
+ CreatedAt time.Time `json:"created_at"`
+ // UpdatedAt is the timestamp when the comment was last updated
+ UpdatedAt time.Time `json:"updated_at"`
+ // Path is the file path where the comment was made
+ Path string `json:"path"`
+ // Line is the line number where the comment was made
+ Line int `json:"line"`
+}
+
+// User represents a GitHub user.
+type User struct {
+ // Login is the GitHub username
+ Login string `json:"login"`
+ // ID is the GitHub user ID
+ ID int64 `json:"id"`
+}
+
+// APIError represents a GitHub API error response.
+type APIError struct {
+ // Message describes the error that occurred
+ Message string `json:"message"`
+ // DocumentationURL points to the relevant API documentation
+ DocumentationURL string `json:"documentation_url"`
+}
+
+// RateLimit represents GitHub API rate limit information.
+type RateLimit struct {
+ // Limit is the maximum number of requests allowed per hour
+ Limit int
+ // Remaining is the number of requests remaining in the current hour
+ Remaining int
+ // Reset is the time when the rate limit will be reset
+ Reset time.Time
+}
+
+// ClientInterface defines the interface for GitHub API interactions.
+type ClientInterface interface {
+ // GetPullRequests fetches pull requests created within the specified date range.
+ // It returns a slice of PullRequest and any error that occurred.
+ GetPullRequests(
+ ctx context.Context,
+ owner, repo string,
+ since, until time.Time,
+ ) ([]PullRequest, error)
+
+ // GetPullRequestReviews fetches all reviews for a specific pull request.
+ // It returns a slice of Review and any error that occurred.
+ GetPullRequestReviews(ctx context.Context, owner, repo string, prNumber int) ([]Review, error)
+
+ // GetPullRequestReviewComments fetches all review comments for a specific pull request.
+ // It returns a slice of ReviewComment and any error that occurred.
+ GetPullRequestReviewComments(
+ ctx context.Context,
+ owner, repo string,
+ prNumber int,
+ ) ([]ReviewComment, error)
+
+ // GetRateLimit returns the current rate limit information.
+ // It returns a RateLimit pointer and any error that occurred.
+ GetRateLimit(ctx context.Context) (*RateLimit, error)
+}
+
+// TestFixtures represents the test data structure.
+type TestFixtures struct {
+ // PullRequests is a slice of sample pull requests
+ PullRequests []PullRequest
+ // Reviews maps PR numbers to their reviews
+ Reviews map[string][]Review
+ // Comments maps PR numbers to their review comments
+ Comments map[string][]ReviewComment
+ // RateLimit contains sample rate limit information
+ RateLimit RateLimit
+}