aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2025-09-27 15:10:13 -0700
committerFranck Cuny <franck@fcuny.net>2025-09-27 15:16:40 -0700
commit7095a53bf173ca6680c7888c8d1df4afb1ad29ae (patch)
tree63ce5037b38ed8704fffc362c7541ebef311b74c /internal
parentfix sha for goget (diff)
downloadx-7095a53bf173ca6680c7888c8d1df4afb1ad29ae.tar.gz
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.
Diffstat (limited to 'internal')
-rw-r--r--internal/github/client.go345
-rw-r--r--internal/github/types.go119
2 files changed, 464 insertions, 0 deletions
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
+}