Files
git-velocity/internal/github/client.go
T
lukaszraczylo 6b16426153 fix(deps): migrate go-github to v92
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.
2026-10-04 11:20:23 +01:00

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
}