mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-10-11 23:20:13 +00:00
Use the v92 module path. NewClient now returns an error and takes options, so pass WithAuthToken and WithHTTPClient and handle the error. Qualify the page field because the issue options embed two structs that have one. Replace the deprecated github.Ptr with the builtin new.
884 lines
24 KiB
Go
884 lines
24 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bradleyfalzon/ghinstallation/v2"
|
|
"github.com/google/go-github/v92/github"
|
|
|
|
"github.com/lukaszraczylo/git-velocity/internal/config"
|
|
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
|
|
"github.com/lukaszraczylo/git-velocity/internal/github/cache"
|
|
)
|
|
|
|
// ProgressCallback is called to report progress during API operations
|
|
type ProgressCallback func(message string)
|
|
|
|
// RetryConfig holds retry settings
|
|
type RetryConfig struct {
|
|
MaxRetries int
|
|
InitialBackoff time.Duration
|
|
MaxBackoff time.Duration
|
|
}
|
|
|
|
// DefaultRetryConfig returns the default retry configuration
|
|
func DefaultRetryConfig() RetryConfig {
|
|
return RetryConfig{
|
|
MaxRetries: 3,
|
|
InitialBackoff: 1 * time.Second,
|
|
MaxBackoff: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Client wraps the GitHub API client with rate limiting and caching
|
|
type Client struct {
|
|
gh *github.Client
|
|
gql *GraphQLClient // GraphQL client for batched queries
|
|
config *config.Config
|
|
cache cache.Cache
|
|
retry RetryConfig
|
|
progress ProgressCallback
|
|
}
|
|
|
|
// NewClient creates a new GitHub client with the appropriate authentication
|
|
func NewClient(ctx context.Context, cfg *config.Config) (*Client, error) {
|
|
var gh *github.Client
|
|
var err error
|
|
|
|
// Determine authentication method
|
|
if cfg.HasGithubToken() {
|
|
gh, err = github.NewClient(github.WithAuthToken(cfg.Auth.GithubToken))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create GitHub client: %w", err)
|
|
}
|
|
} else if cfg.HasGithubApp() {
|
|
// GitHub App authentication
|
|
privateKey, err := cfg.GetGithubAppPrivateKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub App private key: %w", err)
|
|
}
|
|
|
|
itr, err := ghinstallation.New(
|
|
http.DefaultTransport,
|
|
cfg.Auth.GithubApp.AppID,
|
|
cfg.Auth.GithubApp.InstallationID,
|
|
privateKey,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create GitHub App transport: %w", err)
|
|
}
|
|
|
|
gh, err = github.NewClient(github.WithHTTPClient(&http.Client{Transport: itr}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create GitHub client: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("no authentication method configured")
|
|
}
|
|
|
|
// Initialize cache
|
|
var c cache.Cache
|
|
if cfg.Cache.Enabled {
|
|
ttl, err := cfg.GetCacheTTL()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse cache TTL: %w", err)
|
|
}
|
|
c, err = cache.NewFileCache(cfg.Cache.Directory, ttl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize cache: %w", err)
|
|
}
|
|
} else {
|
|
c = cache.NewNoopCache()
|
|
}
|
|
|
|
// Initialize GraphQL client if using token auth (GraphQL doesn't support GitHub App auth easily)
|
|
var gql *GraphQLClient
|
|
if cfg.HasGithubToken() && cfg.Options.UseGraphQL {
|
|
gql = NewGraphQLClient(cfg.Auth.GithubToken)
|
|
}
|
|
|
|
return &Client{
|
|
gh: gh,
|
|
gql: gql,
|
|
config: cfg,
|
|
cache: c,
|
|
retry: DefaultRetryConfig(),
|
|
progress: func(string) {}, // no-op by default
|
|
}, nil
|
|
}
|
|
|
|
// SetProgressCallback sets the callback function for progress reporting
|
|
func (c *Client) SetProgressCallback(cb ProgressCallback) {
|
|
if cb != nil {
|
|
c.progress = cb
|
|
}
|
|
}
|
|
|
|
// HasGraphQL returns true if the GraphQL client is available
|
|
func (c *Client) HasGraphQL() bool {
|
|
return c.gql != nil
|
|
}
|
|
|
|
// FetchPRsWithReviewsGraphQL fetches PRs and reviews using GraphQL (much fewer API calls)
|
|
func (c *Client) FetchPRsWithReviewsGraphQL(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, []models.Review, error) {
|
|
if c.gql == nil {
|
|
return nil, nil, fmt.Errorf("GraphQL client not initialized")
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("gql_prs_reviews:%s/%s:%v:%v", owner, repo, since, until)
|
|
|
|
// Check cache
|
|
type cachedData struct {
|
|
PRs []models.PullRequest
|
|
Reviews []models.Review
|
|
}
|
|
if cached, ok := c.cache.Get(cacheKey); ok {
|
|
if data, ok := cached.(cachedData); ok {
|
|
c.progress(" Using cached PRs and reviews data (GraphQL)")
|
|
return data.PRs, data.Reviews, nil
|
|
}
|
|
}
|
|
|
|
prs, reviews, err := c.gql.FetchPRsWithReviews(ctx, owner, repo, since, until)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Cache results
|
|
c.cache.Set(cacheKey, cachedData{PRs: prs, Reviews: reviews})
|
|
|
|
return prs, reviews, nil
|
|
}
|
|
|
|
// FetchIssuesWithCommentsGraphQL fetches issues and comments using GraphQL (much fewer API calls)
|
|
func (c *Client) FetchIssuesWithCommentsGraphQL(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, []models.IssueComment, error) {
|
|
if c.gql == nil {
|
|
return nil, nil, fmt.Errorf("GraphQL client not initialized")
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("gql_issues_comments:%s/%s:%v:%v", owner, repo, since, until)
|
|
|
|
// Check cache
|
|
type cachedData struct {
|
|
Issues []models.Issue
|
|
Comments []models.IssueComment
|
|
}
|
|
if cached, ok := c.cache.Get(cacheKey); ok {
|
|
if data, ok := cached.(cachedData); ok {
|
|
c.progress(" Using cached issues and comments data (GraphQL)")
|
|
return data.Issues, data.Comments, nil
|
|
}
|
|
}
|
|
|
|
issues, comments, err := c.gql.FetchIssuesWithComments(ctx, owner, repo, since, until)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Cache results
|
|
c.cache.Set(cacheKey, cachedData{Issues: issues, Comments: comments})
|
|
|
|
return issues, comments, nil
|
|
}
|
|
|
|
// retryWithBackoff executes a function with retry logic
|
|
// - For rate limit errors: waits until the limit resets (no retry count limit)
|
|
// - For network/transient errors: uses exponential backoff with MaxRetries limit
|
|
func (c *Client) retryWithBackoff(ctx context.Context, operation string, fn func() error) error {
|
|
var lastErr error
|
|
backoff := c.retry.InitialBackoff
|
|
networkRetries := 0
|
|
|
|
for {
|
|
lastErr = fn()
|
|
if lastErr == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check if error is retryable at all
|
|
if !isRetryableError(lastErr) {
|
|
return lastErr
|
|
}
|
|
|
|
c.progress(fmt.Sprintf(" %s failed: %v", operation, lastErr))
|
|
|
|
// Determine wait strategy based on error type
|
|
if resetTime := getRateLimitResetTime(lastErr); resetTime != nil {
|
|
// Rate limit error - wait until reset, no retry count limit
|
|
waitDuration := time.Until(*resetTime) + time.Second // Add 1s buffer
|
|
if waitDuration < 0 {
|
|
waitDuration = time.Second
|
|
}
|
|
c.progress(fmt.Sprintf(" Rate limit hit. Waiting until %s (%s)...", resetTime.Format("15:04:05"), waitDuration.Round(time.Second)))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(waitDuration):
|
|
}
|
|
// Reset network retry counter after successful rate limit wait
|
|
networkRetries = 0
|
|
backoff = c.retry.InitialBackoff
|
|
} else {
|
|
// Network/transient error - use exponential backoff with retry limit
|
|
networkRetries++
|
|
if networkRetries > c.retry.MaxRetries {
|
|
return fmt.Errorf("%s failed after %d retries: %w", operation, c.retry.MaxRetries, lastErr)
|
|
}
|
|
|
|
c.progress(fmt.Sprintf(" Retry %d/%d for %s (waiting %s)...", networkRetries, c.retry.MaxRetries, operation, backoff))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
|
|
backoff *= 2
|
|
if backoff > c.retry.MaxBackoff {
|
|
backoff = c.retry.MaxBackoff
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// getRateLimitResetTime extracts the reset time from rate limit errors
|
|
func getRateLimitResetTime(err error) *time.Time {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
var rateLimitErr *github.RateLimitError
|
|
if errors.As(err, &rateLimitErr) && rateLimitErr.Rate.Reset.Time.After(time.Now()) {
|
|
t := rateLimitErr.Rate.Reset.Time
|
|
return &t
|
|
}
|
|
|
|
var abuseErr *github.AbuseRateLimitError
|
|
if errors.As(err, &abuseErr) && abuseErr.RetryAfter != nil {
|
|
t := time.Now().Add(*abuseErr.RetryAfter)
|
|
return &t
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isRetryableError checks if an error is retryable
|
|
func isRetryableError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Network errors (timeout only - Temporary() is deprecated)
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) {
|
|
return netErr.Timeout()
|
|
}
|
|
|
|
// GitHub rate limit errors
|
|
var rateLimitErr *github.RateLimitError
|
|
if errors.As(err, &rateLimitErr) {
|
|
return true
|
|
}
|
|
|
|
// GitHub abuse rate limit errors
|
|
var abuseErr *github.AbuseRateLimitError
|
|
if errors.As(err, &abuseErr) {
|
|
return true
|
|
}
|
|
|
|
// Check error message for common transient errors
|
|
errStr := err.Error()
|
|
retryableMessages := []string{
|
|
"connection reset",
|
|
"connection refused",
|
|
"timeout",
|
|
"temporary failure",
|
|
"server error",
|
|
"stream error",
|
|
"CANCEL",
|
|
"EOF",
|
|
"broken pipe",
|
|
"502",
|
|
"503",
|
|
"504",
|
|
}
|
|
for _, msg := range retryableMessages {
|
|
if strings.Contains(strings.ToLower(errStr), strings.ToLower(msg)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ListOrgRepos lists repositories in an organization matching a pattern
|
|
func (c *Client) ListOrgRepos(ctx context.Context, org, pattern string) ([]string, error) {
|
|
var allRepos []string
|
|
|
|
opts := &github.RepositoryListByOrgOptions{
|
|
Type: "all",
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
}
|
|
|
|
for {
|
|
repos, resp, err := c.gh.Repositories.ListByOrg(ctx, org, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list org repos: %w", err)
|
|
}
|
|
|
|
for _, repo := range repos {
|
|
name := repo.GetName()
|
|
if matchPattern(name, pattern) {
|
|
allRepos = append(allRepos, name)
|
|
}
|
|
}
|
|
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
return allRepos, nil
|
|
}
|
|
|
|
// GetCommitCountSince returns the approximate number of commits since a given date.
|
|
// This is used to determine the optimal shallow clone depth.
|
|
// It makes a single lightweight API call with per_page=1 to get pagination info.
|
|
func (c *Client) GetCommitCountSince(ctx context.Context, owner, repo string, since time.Time) (int, error) {
|
|
opts := &github.CommitsListOptions{
|
|
Since: since,
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 1,
|
|
},
|
|
}
|
|
|
|
var resp *github.Response
|
|
err := c.retryWithBackoff(ctx, "get commit count", func() error {
|
|
var err error
|
|
_, resp, err = c.gh.Repositories.ListCommits(ctx, owner, repo, opts)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get commit count: %w", err)
|
|
}
|
|
|
|
// GitHub returns pagination info in the response
|
|
// LastPage indicates total number of pages (with 1 item per page = total commits)
|
|
if resp.LastPage > 0 {
|
|
return resp.LastPage, nil
|
|
}
|
|
|
|
// If LastPage is 0, there's only one page (or no commits)
|
|
// In this case, we need to check if there are any commits at all
|
|
if resp.FirstPage == 0 && resp.NextPage == 0 {
|
|
// Make another call to actually count
|
|
opts.ListOptions.PerPage = 100
|
|
var commits []*github.RepositoryCommit
|
|
err := c.retryWithBackoff(ctx, "count commits", func() error {
|
|
var err error
|
|
commits, _, err = c.gh.Repositories.ListCommits(ctx, owner, repo, opts)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(commits), nil
|
|
}
|
|
|
|
return 1, nil
|
|
}
|
|
|
|
// mainBranches are the branches we consider as "main" branches
|
|
var mainBranches = []string{"main", "master", "develop", "dev"}
|
|
|
|
// FetchPullRequests fetches pull requests from a repository
|
|
// Fetches PRs targeting main branches, filters by merge date
|
|
func (c *Client) FetchPullRequests(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, error) {
|
|
cacheKey := fmt.Sprintf("prs:%s/%s:%v:%v", owner, repo, since, until)
|
|
|
|
// Check cache
|
|
if cached, ok := c.cache.Get(cacheKey); ok {
|
|
if prs, ok := cached.([]models.PullRequest); ok {
|
|
c.progress(" Using cached pull requests data")
|
|
return prs, nil
|
|
}
|
|
}
|
|
|
|
var allPRs []models.PullRequest
|
|
|
|
// Fetch PRs for each main branch separately (API supports base filter)
|
|
for _, baseBranch := range mainBranches {
|
|
prs, err := c.fetchPRsForBranch(ctx, owner, repo, baseBranch, since, until)
|
|
if err != nil {
|
|
// Branch might not exist, skip
|
|
continue
|
|
}
|
|
allPRs = append(allPRs, prs...)
|
|
}
|
|
|
|
c.progress(fmt.Sprintf(" Found %d merged PRs to main branches in date range", len(allPRs)))
|
|
|
|
// Cache results
|
|
c.cache.Set(cacheKey, allPRs)
|
|
|
|
return allPRs, nil
|
|
}
|
|
|
|
// fetchPRsForBranch fetches merged PRs for a specific base branch
|
|
func (c *Client) fetchPRsForBranch(ctx context.Context, owner, repo, baseBranch string, since, until *time.Time) ([]models.PullRequest, error) {
|
|
opts := &github.PullRequestListOptions{
|
|
State: "closed",
|
|
Base: baseBranch,
|
|
Sort: "updated",
|
|
Direction: "desc",
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
}
|
|
|
|
fetcher := &DateFilteredFetcher[*github.PullRequest, models.PullRequest]{
|
|
FetchFn: func(ctx context.Context, page int) ([]*github.PullRequest, *github.Response, error) {
|
|
opts.Page = page
|
|
var prs []*github.PullRequest
|
|
var resp *github.Response
|
|
err := c.retryWithBackoff(ctx, "list pull requests", func() error {
|
|
var err error
|
|
prs, resp, err = c.gh.PullRequests.List(ctx, owner, repo, opts)
|
|
return err
|
|
})
|
|
if page == 1 && len(prs) > 0 {
|
|
c.progress(fmt.Sprintf(" Fetching PRs for branch '%s'...", baseBranch))
|
|
}
|
|
return prs, resp, err
|
|
},
|
|
ConvertFn: func(pr *github.PullRequest) models.PullRequest {
|
|
return convertPullRequest(pr, owner, repo)
|
|
},
|
|
GetDateFn: func(pr *github.PullRequest) time.Time {
|
|
if pr.MergedAt != nil {
|
|
return pr.MergedAt.Time
|
|
}
|
|
return time.Time{} // Will be filtered out by SkipFn
|
|
},
|
|
SkipFn: func(pr *github.PullRequest) bool {
|
|
// Only consider merged PRs
|
|
return pr.MergedAt == nil
|
|
},
|
|
Since: since,
|
|
Until: until,
|
|
}
|
|
|
|
config := FetchConfig{
|
|
ResourceName: "pull requests",
|
|
EarlyTermination: true,
|
|
EarlyTerminationThreshold: 2,
|
|
Quiet: true, // Parent function handles progress
|
|
}
|
|
|
|
return FetchAllPages(ctx, c, "", config, fetcher) // Empty cache key - parent handles caching
|
|
}
|
|
|
|
// FetchReviews fetches reviews for a specific pull request
|
|
func (c *Client) FetchReviews(ctx context.Context, owner, repo string, prNumber int) ([]models.Review, error) {
|
|
cacheKey := fmt.Sprintf("reviews:%s/%s:%d", owner, repo, prNumber)
|
|
|
|
opts := &github.ListOptions{PerPage: 100}
|
|
|
|
fetcher := &SimpleFetcher[*github.PullRequestReview, models.Review]{
|
|
FetchFn: func(ctx context.Context, page int) ([]*github.PullRequestReview, *github.Response, error) {
|
|
opts.Page = page
|
|
var reviews []*github.PullRequestReview
|
|
var resp *github.Response
|
|
err := c.retryWithBackoff(ctx, fmt.Sprintf("list reviews for PR #%d", prNumber), func() error {
|
|
var err error
|
|
reviews, resp, err = c.gh.PullRequests.ListReviews(ctx, owner, repo, prNumber, opts)
|
|
return err
|
|
})
|
|
return reviews, resp, err
|
|
},
|
|
ConvertFn: func(review *github.PullRequestReview) models.Review {
|
|
return convertReview(review, owner, repo, prNumber)
|
|
},
|
|
}
|
|
|
|
config := DefaultFetchConfig("reviews")
|
|
config.EarlyTermination = false // Reviews don't need date-based early termination
|
|
config.Quiet = true // Suppress per-page progress (called many times in parallel)
|
|
|
|
return FetchAllPages(ctx, c, cacheKey, config, fetcher)
|
|
}
|
|
|
|
// FetchIssues fetches issues from a repository
|
|
// Uses early termination when sorted by date - stops when items are outside date range
|
|
func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, error) {
|
|
cacheKey := fmt.Sprintf("issues:%s/%s:%v:%v", owner, repo, since, until)
|
|
|
|
opts := &github.IssueListByRepoOptions{
|
|
State: "all",
|
|
Sort: "created",
|
|
Direction: "desc",
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
}
|
|
|
|
fetcher := &DateFilteredFetcher[*github.Issue, models.Issue]{
|
|
FetchFn: func(ctx context.Context, page int) ([]*github.Issue, *github.Response, error) {
|
|
opts.ListOptions.Page = page
|
|
var issues []*github.Issue
|
|
var resp *github.Response
|
|
err := c.retryWithBackoff(ctx, "list issues", func() error {
|
|
var err error
|
|
issues, resp, err = c.gh.Issues.ListByRepo(ctx, owner, repo, opts)
|
|
return err
|
|
})
|
|
return issues, resp, err
|
|
},
|
|
ConvertFn: func(issue *github.Issue) models.Issue {
|
|
return convertIssue(issue, owner, repo)
|
|
},
|
|
GetDateFn: func(issue *github.Issue) time.Time {
|
|
return issue.GetCreatedAt().Time
|
|
},
|
|
SkipFn: func(issue *github.Issue) bool {
|
|
// Skip pull requests (they appear in issues API)
|
|
return issue.PullRequestLinks != nil
|
|
},
|
|
Since: since,
|
|
Until: until,
|
|
}
|
|
|
|
return FetchAllPages(ctx, c, cacheKey, DefaultFetchConfig("issues"), fetcher)
|
|
}
|
|
|
|
// FetchIssueComments fetches comments on issues from a repository
|
|
// Uses early termination when sorted by date - stops when items are outside date range
|
|
func (c *Client) FetchIssueComments(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.IssueComment, error) {
|
|
cacheKey := fmt.Sprintf("issue_comments:%s/%s:%v:%v", owner, repo, since, until)
|
|
|
|
opts := &github.IssueListCommentsOptions{
|
|
Sort: new("created"),
|
|
Direction: new("desc"),
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
}
|
|
|
|
// Set 'since' parameter if provided (GitHub filters by update time but we'll also filter manually)
|
|
if since != nil {
|
|
opts.Since = since
|
|
}
|
|
|
|
fetcher := &DateFilteredFetcher[*github.IssueComment, models.IssueComment]{
|
|
FetchFn: func(ctx context.Context, page int) ([]*github.IssueComment, *github.Response, error) {
|
|
opts.Page = page
|
|
var comments []*github.IssueComment
|
|
var resp *github.Response
|
|
err := c.retryWithBackoff(ctx, "list issue comments", func() error {
|
|
var err error
|
|
comments, resp, err = c.gh.Issues.ListComments(ctx, owner, repo, 0, opts)
|
|
return err
|
|
})
|
|
return comments, resp, err
|
|
},
|
|
ConvertFn: func(comment *github.IssueComment) models.IssueComment {
|
|
return convertIssueComment(comment, owner, repo)
|
|
},
|
|
GetDateFn: func(comment *github.IssueComment) time.Time {
|
|
return comment.GetCreatedAt().Time
|
|
},
|
|
Since: since,
|
|
Until: until,
|
|
}
|
|
|
|
return FetchAllPages(ctx, c, cacheKey, DefaultFetchConfig("issue comments"), fetcher)
|
|
}
|
|
|
|
// UserProfile contains GitHub user profile information useful for deduplication
|
|
type UserProfile struct {
|
|
ID int64 // GitHub user ID
|
|
Login string // GitHub username
|
|
Name string // Display name
|
|
Email string // Public email (may be empty)
|
|
AvatarURL string
|
|
}
|
|
|
|
// FetchUserProfiles fetches GitHub profiles for a list of logins
|
|
// This is useful for deduplication by getting user IDs, names, and public emails
|
|
func (c *Client) FetchUserProfiles(ctx context.Context, logins []string) (map[string]UserProfile, error) {
|
|
profiles := make(map[string]UserProfile)
|
|
|
|
// Use semaphore to limit concurrent requests
|
|
sem := make(chan struct{}, 10)
|
|
results := make(chan struct {
|
|
login string
|
|
profile UserProfile
|
|
err error
|
|
}, len(logins))
|
|
|
|
for _, login := range logins {
|
|
go func(login string) {
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
cacheKey := fmt.Sprintf("user_profile_%s", login)
|
|
if cached, ok := c.cache.Get(cacheKey); ok {
|
|
if profile, ok := cached.(UserProfile); ok {
|
|
results <- struct {
|
|
login string
|
|
profile UserProfile
|
|
err error
|
|
}{login, profile, nil}
|
|
return
|
|
}
|
|
}
|
|
|
|
var profile UserProfile
|
|
err := c.retryWithBackoff(ctx, "fetch user profile", func() error {
|
|
user, _, err := c.gh.Users.Get(ctx, login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profile = UserProfile{
|
|
ID: user.GetID(),
|
|
Login: user.GetLogin(),
|
|
Name: user.GetName(),
|
|
Email: user.GetEmail(),
|
|
AvatarURL: user.GetAvatarURL(),
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err == nil {
|
|
c.cache.Set(cacheKey, profile)
|
|
}
|
|
results <- struct {
|
|
login string
|
|
profile UserProfile
|
|
err error
|
|
}{login, profile, err}
|
|
}(login)
|
|
}
|
|
|
|
// Collect results
|
|
for range logins {
|
|
r := <-results
|
|
if r.err == nil {
|
|
profiles[r.login] = r.profile
|
|
}
|
|
}
|
|
|
|
return profiles, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func convertPullRequest(pr *github.PullRequest, owner, repo string) models.PullRequest {
|
|
var author models.Author
|
|
if pr.User != nil {
|
|
author = models.Author{
|
|
ID: pr.User.GetID(),
|
|
Login: pr.User.GetLogin(),
|
|
Name: pr.User.GetName(),
|
|
AvatarURL: pr.User.GetAvatarURL(),
|
|
}
|
|
}
|
|
|
|
state := models.PRStateOpen
|
|
if pr.GetMerged() {
|
|
state = models.PRStateMerged
|
|
} else if pr.GetState() == "closed" {
|
|
state = models.PRStateClosed
|
|
}
|
|
|
|
var mergedAt, closedAt *time.Time
|
|
if pr.MergedAt != nil {
|
|
t := pr.MergedAt.Time
|
|
mergedAt = &t
|
|
}
|
|
if pr.ClosedAt != nil {
|
|
t := pr.ClosedAt.Time
|
|
closedAt = &t
|
|
}
|
|
|
|
var baseBranch, headBranch string
|
|
if pr.Base != nil {
|
|
baseBranch = pr.Base.GetRef()
|
|
}
|
|
if pr.Head != nil {
|
|
headBranch = pr.Head.GetRef()
|
|
}
|
|
|
|
return models.PullRequest{
|
|
Number: pr.GetNumber(),
|
|
Title: pr.GetTitle(),
|
|
State: state,
|
|
Author: author,
|
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
|
BaseBranch: baseBranch,
|
|
HeadBranch: headBranch,
|
|
CreatedAt: pr.GetCreatedAt().Time,
|
|
UpdatedAt: pr.GetUpdatedAt().Time,
|
|
MergedAt: mergedAt,
|
|
ClosedAt: closedAt,
|
|
Additions: pr.GetAdditions(),
|
|
Deletions: pr.GetDeletions(),
|
|
FilesChanged: pr.GetChangedFiles(),
|
|
CommitCount: pr.GetCommits(),
|
|
Comments: pr.GetComments() + pr.GetReviewComments(),
|
|
URL: pr.GetHTMLURL(),
|
|
}
|
|
}
|
|
|
|
func convertReview(r *github.PullRequestReview, owner, repo string, prNumber int) models.Review {
|
|
var author models.Author
|
|
if r.User != nil {
|
|
author = models.Author{
|
|
ID: r.User.GetID(),
|
|
Login: r.User.GetLogin(),
|
|
Name: r.User.GetName(),
|
|
AvatarURL: r.User.GetAvatarURL(),
|
|
}
|
|
}
|
|
|
|
state := models.ReviewState(r.GetState())
|
|
|
|
submittedAt := time.Time{}
|
|
if r.SubmittedAt != nil {
|
|
submittedAt = r.SubmittedAt.Time
|
|
}
|
|
|
|
return models.Review{
|
|
ID: r.GetID(),
|
|
PullRequest: prNumber,
|
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
|
Author: author,
|
|
State: state,
|
|
SubmittedAt: submittedAt,
|
|
Body: r.GetBody(),
|
|
}
|
|
}
|
|
|
|
func convertIssueComment(comment *github.IssueComment, owner, repo string) models.IssueComment {
|
|
// Extract issue number from the issue URL
|
|
issueNumber := 0
|
|
if comment.IssueURL != nil {
|
|
// Issue URL format: https://api.github.com/repos/{owner}/{repo}/issues/{number}
|
|
parts := strings.Split(*comment.IssueURL, "/")
|
|
if len(parts) > 0 {
|
|
if num, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
|
|
issueNumber = num
|
|
}
|
|
}
|
|
}
|
|
|
|
var author models.Author
|
|
if comment.User != nil {
|
|
author = models.Author{
|
|
Login: comment.User.GetLogin(),
|
|
Name: comment.User.GetName(),
|
|
AvatarURL: comment.User.GetAvatarURL(),
|
|
}
|
|
}
|
|
|
|
return models.IssueComment{
|
|
ID: comment.GetID(),
|
|
Issue: issueNumber,
|
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
|
Author: author,
|
|
Body: comment.GetBody(),
|
|
CreatedAt: comment.GetCreatedAt().Time,
|
|
}
|
|
}
|
|
|
|
func convertIssue(i *github.Issue, owner, repo string) models.Issue {
|
|
var author models.Author
|
|
if i.User != nil {
|
|
author = models.Author{
|
|
Login: i.User.GetLogin(),
|
|
Name: i.User.GetName(),
|
|
AvatarURL: i.User.GetAvatarURL(),
|
|
}
|
|
}
|
|
|
|
state := models.IssueStateOpen
|
|
if i.GetState() == "closed" {
|
|
state = models.IssueStateClosed
|
|
}
|
|
|
|
var closedAt *time.Time
|
|
var closedBy *models.Author
|
|
if i.ClosedAt != nil {
|
|
t := i.ClosedAt.Time
|
|
closedAt = &t
|
|
}
|
|
if i.ClosedBy != nil {
|
|
cb := models.Author{
|
|
Login: i.ClosedBy.GetLogin(),
|
|
AvatarURL: i.ClosedBy.GetAvatarURL(),
|
|
}
|
|
closedBy = &cb
|
|
}
|
|
|
|
var labels []string
|
|
for _, l := range i.Labels {
|
|
labels = append(labels, l.GetName())
|
|
}
|
|
|
|
return models.Issue{
|
|
Number: i.GetNumber(),
|
|
Title: i.GetTitle(),
|
|
State: state,
|
|
Author: author,
|
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
|
CreatedAt: i.GetCreatedAt().Time,
|
|
UpdatedAt: i.GetUpdatedAt().Time,
|
|
ClosedAt: closedAt,
|
|
ClosedBy: closedBy,
|
|
Comments: i.GetComments(),
|
|
Labels: labels,
|
|
URL: i.GetHTMLURL(),
|
|
}
|
|
}
|
|
|
|
// matchPattern performs simple glob-style pattern matching
|
|
func matchPattern(s, pattern string) bool {
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
|
|
// Handle exact match
|
|
if !strings.Contains(pattern, "*") {
|
|
return s == pattern
|
|
}
|
|
|
|
// Handle prefix match (pattern*)
|
|
if strings.HasSuffix(pattern, "*") && !strings.HasPrefix(pattern, "*") {
|
|
return strings.HasPrefix(s, strings.TrimSuffix(pattern, "*"))
|
|
}
|
|
|
|
// Handle suffix match (*pattern)
|
|
if strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, "*") {
|
|
return strings.HasSuffix(s, strings.TrimPrefix(pattern, "*"))
|
|
}
|
|
|
|
// Handle contains match (*pattern*)
|
|
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
|
|
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
|
|
return strings.Contains(s, inner)
|
|
}
|
|
|
|
return false
|
|
}
|