aboutsummaryrefslogtreecommitdiff
path: root/internal/github
diff options
context:
space:
mode:
Diffstat (limited to 'internal/github')
-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
+}