package github import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) const ( githubAPIBase = "https://api.github.com" githubAccept = "application/vnd.github+json" githubAPIVersion = "2022-11-28" ) // accessibleReposAffiliation is the GitHub /user/repos affiliation filter that // returns every repo the authenticated user can reach in one call: their own // repos (owner), repos they collaborate on, and repos in orgs they belong to. // This replaces the per-org search/repositories fan-out (which burns the 30/min // search quota) with a single call on the 5000/min core quota. const accessibleReposAffiliation = "owner,collaborator,organization_member" // GitHubAPIError represents an error response from the GitHub API with a status code. type GitHubAPIError struct { StatusCode int Endpoint string Body string } func (e *GitHubAPIError) Error() string { return fmt.Sprintf("GitHub API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body) } // WithRateTracker attaches a rate tracker so response headers are recorded. // Returns the client for chaining; safe to call before any requests are made. func (c *PATClient) WithRateTracker(t *RateTracker) *PATClient { c.rateTracker = t return c } // recordRateHeaders feeds rate-limit data from a response into the tracker. // endpoint is used to pick the default resource bucket when the response // omits the X-RateLimit-Resource header. func (c *PATClient) recordRateHeaders(resp *http.Response, endpoint string) { if c.rateTracker == nil || resp == nil { return } defaultResource := ResourceCore if strings.HasPrefix(endpoint, "/search/") { defaultResource = ResourceSearch } else if strings.HasPrefix(endpoint, "/graphql") { defaultResource = ResourceGraphQL } snap, headersOK := parseRateHeaders(resp, defaultResource) if headersOK { c.rateTracker.Record(snap) } if !isRateLimitStatus(resp.StatusCode) { return } // On a 429, prefer the real X-RateLimit-Reset over the synthetic 1h // fallback. parseRateHeaders may return ok=true with Remaining>0 if the // secondary-limit response carried stale headers — only skip the // fallback when the headers themselves report exhaustion (Remaining<=0 // + future ResetAt). Otherwise fall through to the conservative pause. if headersOK && snap.Exhausted() { return } c.rateTracker.markRateExhausted(defaultResource, time.Time{}) } // isRateLimitStatus returns true for status codes GitHub uses to signal // primary or secondary rate-limit exhaustion. 403 is documented for both // abuse-detection and primary limits when the body indicates so; 429 is // secondary limits. func isRateLimitStatus(status int) bool { return status == http.StatusTooManyRequests } // maybeMarkRateExhaustedFromBody flags a rate-limit hit when GitHub returned // a 403/429 whose body contains the rate-limit prose. The headers may be // missing on these responses (esp. secondary limits), so the body is the // authoritative signal. func (c *PATClient) maybeMarkRateExhaustedFromBody(endpoint string, status int, body []byte) { if c.rateTracker == nil { return } if status != http.StatusForbidden && status != http.StatusTooManyRequests { return } lower := strings.ToLower(string(body)) if !strings.Contains(lower, "rate limit") && !strings.Contains(lower, "abuse detection") { return } resource := ResourceCore if strings.HasPrefix(endpoint, "/search/") { resource = ResourceSearch } else if strings.HasPrefix(endpoint, "/graphql") { resource = ResourceGraphQL } // If recordRateHeaders already captured a real reset for this bucket on // the same response, don't clobber it with the synthetic 1h fallback. if existing, ok := c.rateTracker.Snapshot(resource); ok && existing.Exhausted() { return } c.rateTracker.markRateExhausted(resource, time.Time{}) } // setGitHubHeaders sets the common Authorization, Accept, and API version headers. func (c *PATClient) setGitHubHeaders(req *http.Request) { req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Accept", githubAccept) req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) } func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) { _, err := c.GetAuthenticatedUser(ctx) return err == nil, nil } func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) { if c.username != "" { return c.username, nil } var user struct { Login string `json:"login"` } if err := c.get(ctx, "/user", &user); err != nil { return "", err } c.username = user.Login return c.username, nil } // HasRepositoryAccess checks the repository root without invoking any // operation-specific endpoint or mutating service caches. func (c *PATClient) HasRepositoryAccess(ctx context.Context, owner, repo string) (bool, error) { var metadata struct { FullName string `json:"full_name"` } endpoint := fmt.Sprintf("/repos/%s/%s", owner, repo) if err := c.get(ctx, endpoint, &metadata); err != nil { return false, err } return metadata.FullName != "", nil } // GetRepository returns the provider-authoritative repository identity and // permissions used by managed contribution-destination preparation. func (c *PATClient) GetRepository(ctx context.Context, owner, repo string) (*GitHubRepository, error) { var raw githubRepositoryResponse endpoint := fmt.Sprintf("/repos/%s/%s", owner, repo) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("get repository %s/%s: %w", owner, repo, err) } return projectGitHubRepository(raw), nil } // ListRepositoryForks lists every fork in the canonical repository's fork // network, including forks whose names differ from the canonical name. func (c *PATClient) ListRepositoryForks(ctx context.Context, owner, repo string) ([]*GitHubRepository, error) { endpoint := fmt.Sprintf("/repos/%s/%s/forks?per_page=100", owner, repo) forks := make([]*GitHubRepository, 0) for endpoint != "" { var page []githubRepositoryResponse next, err := c.getPaginated(ctx, endpoint, &page) if err != nil { return nil, fmt.Errorf("list repository forks for %s/%s: %w", owner, repo, err) } for _, raw := range page { forks = append(forks, projectGitHubRepository(raw)) } endpoint = next } return forks, nil } // CreateFork creates a fork for the authenticated human automation identity. // GitHub may return before the fork is fully readable; the service resolver // performs the bounded follow-up verification. func (c *PATClient) CreateFork(ctx context.Context, owner, repo string) (*GitHubRepository, error) { var raw githubRepositoryResponse endpoint := fmt.Sprintf("/repos/%s/%s/forks", owner, repo) if err := c.postJSON(ctx, endpoint, []byte(`{}`), &raw); err != nil { return nil, fmt.Errorf("create fork for %s/%s: %w", owner, repo, err) } return projectGitHubRepository(raw), nil } func (c *PATClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) { var raw patPR endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("get PR #%d: %w", number, err) } return convertPatPR(&raw, owner, repo), nil } func (c *PATClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) { var raw patIssue endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("get issue #%d: %w", number, err) } return convertPatIssue(&raw, owner, repo), nil } func (c *PATClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) { statuses, err := runBatchedBranchQuery(ctx, c, []graphQLBranchRef{{ Owner: owner, Repo: repo, Branch: branch, }}) if err != nil { return nil, fmt.Errorf("find PR by branch %q: %w", branch, err) } status := statuses.Statuses[graphqlBranchKey(owner, repo, branch)] if status == nil { return nil, nil } return status.PR, nil } func (c *PATClient) FindPRByHead(ctx context.Context, owner, repo, headOwner, headRepo, branch string) (*PR, error) { var raw []patPR head := url.QueryEscape(headOwner + ":" + branch) endpoint := fmt.Sprintf("/repos/%s/%s/pulls?state=open&head=%s&per_page=100", owner, repo, head) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("find PR by head %q:%q: %w", headOwner, branch, err) } for i := range raw { pr := convertPatPR(&raw[i], owner, repo) if sameRepositoryIdentity(pr.HeadRepoOwner, pr.HeadRepoName, headOwner, headRepo) { return pr, nil } } return nil, nil } func (c *PATClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) { user, err := c.GetAuthenticatedUser(ctx) if err != nil { return nil, err } var raw []patPR endpoint := fmt.Sprintf("/repos/%s/%s/pulls?state=open&per_page=100", owner, repo) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("list PRs: %w", err) } var result []*PR for i := range raw { if raw[i].User.Login == user { result = append(result, convertPatPR(&raw[i], owner, repo)) } } return result, nil } func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) { query := buildReviewSearchQuery(scope, filter, customQuery) var result struct { Items []patSearchItem `json:"items"` } endpoint := "/search/issues?q=" + url.QueryEscape(query) + "&per_page=50" if err := c.get(ctx, endpoint, &result); err != nil { return nil, fmt.Errorf("search review-requested: %w", err) } prs := make([]*PR, len(result.Items)) for i, item := range result.Items { prs[i] = convertSearchItemToPR( item.ID, item.NodeID, item.Number, item.Title, item.HTMLURL, item.State, item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt, item.Draft, item.CreatedAt, item.UpdatedAt, ) } return prs, nil } func (c *PATClient) SearchPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) { page, err := c.SearchPRsPaged(ctx, filter, customQuery, 1, 50) if err != nil { return nil, err } return page.PRs, nil } func (c *PATClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { page, perPage = clampSearchPage(page, perPage) query := buildPRSearchQuery(filter, customQuery) var result struct { TotalCount int `json:"total_count"` Items []patSearchItem `json:"items"` } endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d", url.QueryEscape(query), perPage, page) if err := c.get(ctx, endpoint, &result); err != nil { return nil, fmt.Errorf("search PRs: %w", err) } prs := make([]*PR, len(result.Items)) for i, item := range result.Items { prs[i] = convertSearchItemToPR( item.ID, item.NodeID, item.Number, item.Title, item.HTMLURL, item.State, item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt, item.Draft, item.CreatedAt, item.UpdatedAt, ) } return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil } func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) { page, err := c.ListIssuesPaged(ctx, filter, customQuery, 1, 50) if err != nil { return nil, err } return page.Issues, nil } func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { page, perPage = clampSearchPage(page, perPage) query := buildIssueSearchQuery(filter, customQuery) var result struct { TotalCount int `json:"total_count"` Items []issueSearchItem `json:"items"` } endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d", url.QueryEscape(query), perPage, page) if err := c.get(ctx, endpoint, &result); err != nil { return nil, fmt.Errorf("search issues: %w", err) } issues := parseIssueSearchResults(result.Items) return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil } func (c *PATClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) { var result struct { State string `json:"state"` } endpoint := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) if err := c.get(ctx, endpoint, &result); err != nil { return "", fmt.Errorf("get issue state: %w", err) } return result.State, nil } func (c *PATClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) { var raw []struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` } if err := c.get(ctx, "/user/orgs?per_page=100", &raw); err != nil { return nil, fmt.Errorf("list user orgs: %w", err) } orgs := make([]GitHubOrg, len(raw)) for i, r := range raw { orgs[i] = GitHubOrg{Login: r.Login, AvatarURL: r.AvatarURL} } return orgs, nil } func (c *PATClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) { q := "org:" + org if query != "" { q += " " + query } limit = clampRepoSearchLimit(limit) endpoint := fmt.Sprintf("/search/repositories?q=%s&per_page=%d", url.QueryEscape(q), limit) repos, err := c.fetchRepoSearch(ctx, endpoint) if err != nil { return nil, fmt.Errorf("search org repos: %w", err) } return repos, nil } func (c *PATClient) ListUserRepos(ctx context.Context, query string, limit int) ([]GitHubRepo, error) { user, err := c.GetAuthenticatedUser(ctx) if err != nil { return nil, fmt.Errorf("list user repos: %w", err) } q := "user:" + user if query != "" { q += " " + query } limit = clampRepoSearchLimit(limit) endpoint := fmt.Sprintf("/search/repositories?q=%s&per_page=%d", url.QueryEscape(q), limit) repos, err := c.fetchRepoSearch(ctx, endpoint) if err != nil { return nil, fmt.Errorf("list user repos: %w", err) } return repos, nil } // ListAccessibleRepos lists every repo the authenticated user can access via a // single GET /user/repos call on the core REST quota. The response is a flat // JSON array (not a search wrapper), so it decodes differently from // fetchRepoSearch. // // query is a BEST-EFFORT substring filter applied only over the first `limit` // repos returned by this single (un-paginated) page — a query matching a repo // beyond the cap returns nothing here. This is intentional: the picker fetches // limit=100 and the frontend performs the canonical, comprehensive client-side // filtering over that page, so the server filter is just an optional narrowing. // Do NOT rely on it for completeness, and do NOT add pagination to "fix" it // without revisiting the picker contract. func (c *PATClient) ListAccessibleRepos(ctx context.Context, query string, limit int) ([]GitHubRepo, error) { limit = clampRepoSearchLimit(limit) if c.principal.Kind == TokenCredentialInstallation { endpoint := fmt.Sprintf("/installation/repositories?per_page=%d", limit) var response struct { Repositories []repoListItem `json:"repositories"` } if err := c.get(ctx, endpoint, &response); err != nil { return nil, fmt.Errorf("list installation repositories: %w", err) } return filterReposByQuery(convertRepoListItems(response.Repositories), query), nil } endpoint := fmt.Sprintf("/user/repos?affiliation=%s&sort=pushed&per_page=%d", url.QueryEscape(accessibleReposAffiliation), limit) var items []repoListItem if err := c.get(ctx, endpoint, &items); err != nil { return nil, fmt.Errorf("list accessible repos: %w", err) } return filterReposByQuery(convertRepoListItems(items), query), nil } // repoListItem is the per-repo JSON shape returned by the flat-array endpoints // (GET /user/repos). The search endpoints wrap the same fields under `.items`. type repoListItem struct { FullName string `json:"full_name"` Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` Private bool `json:"private"` DefaultBranch string `json:"default_branch"` Description string `json:"description"` PushedAt time.Time `json:"pushed_at"` } // convertRepoListItems maps the raw flat-array items into the lightweight // GitHubRepo shape, preserving PushedAt as a pointer so callers can sort by // recency (a zero pushed_at becomes nil). func convertRepoListItems(items []repoListItem) []GitHubRepo { repos := make([]GitHubRepo, len(items)) for i, item := range items { repos[i] = GitHubRepo{ FullName: item.FullName, Owner: item.Owner.Login, Name: item.Name, Private: item.Private, DefaultBranch: item.DefaultBranch, Description: item.Description, } if !item.PushedAt.IsZero() { t := item.PushedAt repos[i].PushedAt = &t } } return repos } // filterReposByQuery returns the repos whose full_name contains query // (case-insensitive). An empty query returns the input unchanged. Best-effort: // it filters only the already-fetched (capped) slice — see ListAccessibleRepos. func filterReposByQuery(repos []GitHubRepo, query string) []GitHubRepo { if query == "" { return repos } needle := strings.ToLower(query) filtered := make([]GitHubRepo, 0, len(repos)) for _, r := range repos { if strings.Contains(strings.ToLower(r.FullName), needle) { filtered = append(filtered, r) } } return filtered } // fetchRepoSearch executes a /search/repositories request and decodes the // items into the lightweight GitHubRepo shape used for autocomplete and the // list-accessible-repos endpoint. PushedAt is captured so callers can sort // merged results by recency. func (c *PATClient) fetchRepoSearch(ctx context.Context, endpoint string) ([]GitHubRepo, error) { var result struct { Items []struct { FullName string `json:"full_name"` Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` Private bool `json:"private"` DefaultBranch string `json:"default_branch"` Description string `json:"description"` PushedAt time.Time `json:"pushed_at"` } `json:"items"` } if err := c.get(ctx, endpoint, &result); err != nil { return nil, err } repos := make([]GitHubRepo, len(result.Items)) for i, item := range result.Items { repos[i] = GitHubRepo{ FullName: item.FullName, Owner: item.Owner.Login, Name: item.Name, Private: item.Private, DefaultBranch: item.DefaultBranch, Description: item.Description, } if !item.PushedAt.IsZero() { t := item.PushedAt repos[i].PushedAt = &t } } return repos, nil } // clampRepoSearchLimit applies the default and GitHub's per_page=100 cap. func clampRepoSearchLimit(limit int) int { if limit <= 0 { return 20 } if limit > 100 { return 100 } return limit } func (c *PATClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) { var raw []struct { ID int64 `json:"id"` State string `json:"state"` Body string `json:"body"` SubmittedAt time.Time `json:"submitted_at"` User struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` } `json:"user"` } endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, err } reviews := make([]PRReview, len(raw)) for i, r := range raw { reviews[i] = PRReview{ ID: r.ID, Author: r.User.Login, AuthorAvatar: r.User.AvatarURL, State: r.State, Body: r.Body, CreatedAt: r.SubmittedAt, } } return reviews, nil } func (c *PATClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments?per_page=100", owner, repo, number) if since != nil { endpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339)) } var reviewRaw []ghComment if err := c.get(ctx, endpoint, &reviewRaw); err != nil { return nil, err } issueEndpoint := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?per_page=100", owner, repo, number) if since != nil { issueEndpoint += "&since=" + url.QueryEscape(since.Format(time.RFC3339)) } var issueRaw []ghIssueComment if err := c.get(ctx, issueEndpoint, &issueRaw); err != nil { return nil, err } return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil } func (c *PATClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) { var checkRunsRaw []ghCheckRun endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs?per_page=100", owner, repo, ref) for endpoint != "" { var page struct { CheckRuns []ghCheckRun `json:"check_runs"` } next, err := c.getPaginated(ctx, endpoint, &page) if err != nil { return nil, err } checkRunsRaw = append(checkRunsRaw, page.CheckRuns...) endpoint = next } var statusResult struct { Statuses []ghStatusContext `json:"statuses"` } statusEndpoint := fmt.Sprintf("/repos/%s/%s/commits/%s/status", owner, repo, ref) if err := c.get(ctx, statusEndpoint, &statusResult); err != nil { return nil, err } return mergeChecks( convertRawCheckRuns(checkRunsRaw), convertRawStatusContexts(statusResult.Statuses), ), nil } func (c *PATClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) { return getPRFeedback(ctx, c, owner, repo, number) } func (c *PATClient) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) { return getPRStatus(ctx, c, owner, repo, number) } func (c *PATClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) { var raw []ghPRFile endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/files?per_page=100", owner, repo, number) if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("list PR files: %w", err) } return convertRawPRFiles(raw), nil } func (c *PATClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/commits?per_page=100", owner, repo, number) var raw []ghPRCommit for endpoint != "" { var page []ghPRCommit next, err := c.getPaginated(ctx, endpoint, &page) if err != nil { return nil, fmt.Errorf("list PR commits: %w", err) } raw = append(raw, page...) endpoint = next } return convertRawPRCommits(raw), nil } func (c *PATClient) GetPRCommitDetail(ctx context.Context, owner, repo, sha string) (PRCommitDetail, error) { if err := validateGitHubCommitSHA(sha); err != nil { return PRCommitDetail{}, err } var pages []ghPRCommitDetail endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s?per_page=100", owner, repo, sha) for endpoint != "" { var page ghPRCommitDetail next, err := c.getPaginated(ctx, endpoint, &page) if err != nil { return PRCommitDetail{}, fmt.Errorf("get PR commit detail: %w", err) } pages = append(pages, page) endpoint = next } encoded, err := json.Marshal(pages) if err != nil { return PRCommitDetail{}, fmt.Errorf("encode PR commit detail pages: %w", err) } return parsePRCommitDetailJSON(string(encoded)) } func (c *PATClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) { var branches []RepoBranch endpoint := fmt.Sprintf("/repos/%s/%s/branches?per_page=100", owner, repo) for endpoint != "" { var page []struct { Name string `json:"name"` } nextLink, err := c.getPaginated(ctx, endpoint, &page) if err != nil { return nil, fmt.Errorf("list repo branches: %w", err) } for _, b := range page { branches = append(branches, RepoBranch{Name: b.Name}) } endpoint = nextLink } return branches, nil } func (c *PATClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) { var raw struct { AllowMergeCommit *bool `json:"allow_merge_commit"` AllowSquashMerge *bool `json:"allow_squash_merge"` AllowRebaseMerge *bool `json:"allow_rebase_merge"` } if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo), &raw); err != nil { return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %w", err) } // Conservative read: missing field → false. A permission-gated response // that omits allow_* would otherwise let us pick a disallowed method // (e.g. "merge" on a rebase-only repo), reproducing the 405 this fix is // designed to prevent. allowed := func(p *bool) bool { return p != nil && *p } return RepoMergeMethods{ Merge: allowed(raw.AllowMergeCommit), Squash: allowed(raw.AllowSquashMerge), Rebase: allowed(raw.AllowRebaseMerge), }, nil } func (c *PATClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number) payload := map[string]string{"event": event} if body != "" { payload["body"] = body } jsonBody, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal review payload: %w", err) } return c.post(ctx, endpoint, jsonBody) } func (c *PATClient) RequestReviewers(ctx context.Context, owner, repo string, number int, reviewers []string) error { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, number) jsonBody, err := json.Marshal(struct { Reviewers []string `json:"reviewers"` }{Reviewers: reviewers}) if err != nil { return fmt.Errorf("marshal requested reviewers payload: %w", err) } return c.post(ctx, endpoint, jsonBody) } func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, request MergePRRequest) (MergeOutcome, error) { endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge-async", owner, repo, number) payload := map[string]string{"merge_action": "default"} if request.MergeMethod != "" { payload["merge_method"] = request.MergeMethod } if request.ExpectedHeadSHA != "" { payload["sha"] = request.ExpectedHeadSHA } jsonBody, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("marshal merge payload: %w", err) } var response mergeAsyncResponse if err := c.putJSON(ctx, endpoint, jsonBody, &response); err != nil { var apiErr *GitHubAPIError if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict || json.Unmarshal([]byte(apiErr.Body), &response) != nil || response.Details.UUID == "" { return "", err } } for response.Status == "pending" { if response.Details.UUID == "" { return "", fmt.Errorf("GitHub merge response is pending without a UUID") } wait := c.mergePollWait if wait == nil { wait = waitForMergePoll } if waitErr := wait(ctx, mergePollInterval); waitErr != nil { return "", fmt.Errorf("wait to poll merge PR #%d: %w", number, waitErr) } if err := c.get(ctx, endpoint+"/"+response.Details.UUID, &response); err != nil { return "", err } } if response.Status == mergeStatusFailed { return "", newMergeFailureError(response.Details) } return normalizeMergeOutcome(response.Status) } func (c *PATClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) { payload := struct { Description string `json:"description,omitempty"` Public bool `json:"public"` Files map[string]gistFileDTO `json:"files"` }{ Description: in.Description, Public: in.Public, Files: make(map[string]gistFileDTO, len(in.Files)), } for name, f := range in.Files { payload.Files[name] = gistFileDTO(f) } body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal gist payload: %w", err) } var resp GistResponse if err := c.postJSON(ctx, "/gists", body, &resp); err != nil { return nil, fmt.Errorf("create gist: %w", err) } return &resp, nil } func (c *PATClient) DeleteGist(ctx context.Context, gistID string) error { if gistID == "" { return fmt.Errorf("delete gist: empty id") } return c.delete(ctx, "/gists/"+gistID) } // ListRepoDirectory lists the entries of a directory in a repository at the // given ref via the REST contents API. A 404 (missing directory) surfaces as // a *GitHubAPIError via the shared get() helper. func (c *PATClient) ListRepoDirectory(ctx context.Context, owner, repo, dir, ref string) ([]RepoContentEntry, error) { endpoint := repoContentsEndpoint(owner, repo, dir, ref) var entries []RepoContentEntry if err := c.get(ctx, endpoint, &entries); err != nil { return nil, fmt.Errorf("list repo directory: %w", err) } return entries, nil } // ghRepoFileContent mirrors the GitHub contents API's per-file response // shape (only the fields GetRepoFileContent needs). type ghRepoFileContent struct { Type string `json:"type"` Encoding string `json:"encoding"` Content string `json:"content"` } // GetRepoFileContent fetches the raw decoded content of a single file via // the REST contents API. A 404 (missing file) surfaces as a *GitHubAPIError // via the shared get() helper. func (c *PATClient) GetRepoFileContent(ctx context.Context, owner, repo, path, ref string) ([]byte, error) { endpoint := repoContentsEndpoint(owner, repo, path, ref) var raw ghRepoFileContent if err := c.get(ctx, endpoint, &raw); err != nil { return nil, fmt.Errorf("get repo file content: %w", err) } if raw.Encoding != "base64" { return nil, fmt.Errorf("get repo file content: unsupported encoding %q", raw.Encoding) } decoded, err := base64.StdEncoding.DecodeString(stripBase64Whitespace(raw.Content)) if err != nil { return nil, fmt.Errorf("get repo file content: decode base64: %w", err) } return decoded, nil } // gistFileDTO mirrors the GitHub API's per-file body shape. type gistFileDTO struct { Content string `json:"content"` } // post makes an authenticated HTTP POST to the GitHub API. // Errors on non-2xx are returned as plain `fmt.Errorf` (not `*GitHubAPIError`), // so callers cannot recover the HTTP status via `errors.As`. Use `put` (or // switch to wrapping in `GitHubAPIError`) if the caller needs per-status // mapping like the merge endpoint does. func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) if err != nil { return err } c.setGitHubHeaders(req) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request POST %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) return fmt.Errorf("GitHub API POST %s returned %d: %s", endpoint, resp.StatusCode, string(respBody)) } return nil } // postJSON sends a POST and decodes the response body into result. // 2xx with no body returns nil; 4xx/5xx returns a *GitHubAPIError. func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error { return c.requestJSON(ctx, http.MethodPost, endpoint, body, result) } func (c *PATClient) requestJSON( ctx context.Context, method string, endpoint string, body []byte, result interface{}, ) error { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body)) if err != nil { return err } c.setGitHubHeaders(req) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request %s %s: %w", method, endpoint, err) } defer func() { _ = resp.Body.Close() }() c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} } if result == nil { return nil } return json.NewDecoder(resp.Body).Decode(result) } // delete sends a DELETE request. 2xx and 404 both return nil-or-typed-error per caller intent. // Here we return a typed error on any non-2xx so callers can inspect for 404. func (c *PATClient) delete(ctx context.Context, endpoint string) error { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) if err != nil { return err } c.setGitHubHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request DELETE %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} } return nil } func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error { return c.putJSON(ctx, endpoint, body, nil) } func (c *PATClient) putJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error { return c.requestJSON(ctx, http.MethodPut, endpoint, body, result) } func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error { url := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } c.setGitHubHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } return json.NewDecoder(resp.Body).Decode(result) } // getPaginated is like get but also returns the "next" link from the Link header, if any. func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result interface{}) (string, error) { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", err } c.setGitHubHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return "", &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return "", err } return parseNextLink(resp.Header.Get("Link")), nil } // parseNextLink extracts the "next" URL path+query from a GitHub Link header. func parseNextLink(header string) string { if header == "" { return "" } for _, part := range strings.Split(header, ",") { part = strings.TrimSpace(part) if !strings.Contains(part, `rel="next"`) { continue } start := strings.Index(part, "<") end := strings.Index(part, ">") if start < 0 || end < 0 || end <= start { continue } link := part[start+1 : end] // Strip the base URL to return just the path+query for our get method if strings.HasPrefix(link, githubAPIBase) { return link[len(githubAPIBase):] } return link } return "" } // FetchBranchProtection looks up the branch protection rule on a repo's base // branch via the REST API. A 404 is treated as "no rule configured" and a 403 // is treated as "no rule we can see" (token lacks Administration: Read scope). // Both return HasRule=false with no error so the cache stores the negative // result and we don't burn rate-limit quota retrying every poll cycle. // Other errors propagate so callers can decide whether to retry. func (c *PATClient) FetchBranchProtection(ctx context.Context, owner, repo, branch string) (BranchProtection, error) { endpoint := fmt.Sprintf("/repos/%s/%s/branches/%s/protection", owner, repo, branch) var raw struct { RequiredPullRequestReviews *struct { RequiredApprovingReviewCount int `json:"required_approving_review_count"` } `json:"required_pull_request_reviews"` } if err := c.get(ctx, endpoint, &raw); err != nil { var apiErr *GitHubAPIError if errors.As(err, &apiErr) { if apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusForbidden { return BranchProtection{HasRule: false}, nil } } return BranchProtection{}, err } if raw.RequiredPullRequestReviews == nil { return BranchProtection{HasRule: true}, nil } return BranchProtection{ HasRule: true, RequiredApprovingReviewCount: raw.RequiredPullRequestReviews.RequiredApprovingReviewCount, }, nil } // patPR is the JSON shape from the GitHub REST API for PRs. type patPR struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` Body string `json:"body"` State string `json:"state"` // Draft is a pointer: AC-12a requires distinguishing an omitted or null // draft from a genuine false, which a plain bool can't after decode. Draft *bool `json:"draft"` Mergeable *bool `json:"mergeable"` MergeableState string `json:"mergeable_state"` Additions int `json:"additions"` Deletions int `json:"deletions"` // ChangedFiles is a pointer for the same reason as Draft (AC-12a): 0 is // a legitimate observation and must stay distinguishable from absent/null. ChangedFiles *int `json:"changed_files"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` MergedAt *string `json:"merged_at"` ClosedAt *string `json:"closed_at"` // MergedBy is absent from the REST pulls response for an unmerged PR; // its zero-value Login must never be written as a placeholder. MergedBy *struct { Login string `json:"login"` } `json:"merged_by"` // AutoMerge is present as a non-null object only while auto-merge is // armed; GitHub clears it to null once it fires, so this can only ever // mean "armed at fetch time" — never "merged by auto-merge". AutoMerge *struct{} `json:"auto_merge"` RequestedReviewers []struct { Login string `json:"login"` } `json:"requested_reviewers"` RequestedTeams []struct { Slug string `json:"slug"` Name string `json:"name"` } `json:"requested_teams"` User struct { Login string `json:"login"` } `json:"user"` Head struct { Ref string `json:"ref"` SHA string `json:"sha"` } `json:"head"` Base struct { Ref string `json:"ref"` } `json:"base"` MaintainerCanModify bool `json:"maintainer_can_modify"` HeadRepository *patRepository `json:"-"` BaseRepository *patRepository `json:"-"` } type patRepository struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` Owner struct { Login string `json:"login"` } `json:"owner"` } func (p *patPR) UnmarshalJSON(data []byte) error { type plain patPR var decoded plain if err := json.Unmarshal(data, &decoded); err != nil { return err } *p = patPR(decoded) var nested struct { Head struct { Repo *patRepository `json:"repo"` } `json:"head"` Base struct { Repo *patRepository `json:"repo"` } `json:"base"` } if err := json.Unmarshal(data, &nested); err != nil { return err } p.HeadRepository = nested.Head.Repo p.BaseRepository = nested.Base.Repo return nil } type patIssue struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` Body string `json:"body"` State string `json:"state"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ClosedAt *string `json:"closed_at"` User struct { Login string `json:"login"` } `json:"user"` Labels []struct { Name string `json:"name"` } `json:"labels"` Assignees []struct { Login string `json:"login"` } `json:"assignees"` } type patSearchItem struct { ID int64 `json:"id"` NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` State string `json:"state"` Draft bool `json:"draft"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` RepositoryURL string `json:"repository_url"` User struct { Login string `json:"login"` } `json:"user"` PullRequest struct { MergedAt string `json:"merged_at"` } `json:"pull_request"` } func convertPatPR(raw *patPR, owner, repo string) *PR { state := strings.ToLower(raw.State) if raw.MergedAt != nil && *raw.MergedAt != "" { state = prStateMerged } mergeable := false if raw.Mergeable != nil { mergeable = *raw.Mergeable } mergedByLogin := "" if raw.MergedBy != nil { mergedByLogin = raw.MergedBy.Login } draft, changedFiles := false, 0 if raw.Draft != nil { draft = *raw.Draft } if raw.ChangedFiles != nil { changedFiles = *raw.ChangedFiles } pr := &PR{ Number: raw.Number, Title: raw.Title, HTMLURL: raw.HTMLURL, Body: raw.Body, State: state, HeadBranch: raw.Head.Ref, HeadSHA: raw.Head.SHA, BaseBranch: raw.Base.Ref, AuthorLogin: raw.User.Login, RepoOwner: owner, RepoName: repo, MaintainerCanModify: raw.MaintainerCanModify, Draft: draft, IsDraftObserved: raw.Draft != nil, Mergeable: mergeable, MergeableState: strings.ToLower(raw.MergeableState), Additions: raw.Additions, Deletions: raw.Deletions, ChangedFiles: changedFiles, ChangedFilesObserved: raw.ChangedFiles != nil, MergedByLogin: mergedByLogin, AutoMergeEnabled: raw.AutoMerge != nil, RequestedReviewers: convertPatRequestedReviewers(raw), CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, } applyPatHeadRepository(pr, raw.HeadRepository) applyPatBaseRepository(pr, raw.BaseRepository) if raw.MergedAt != nil { pr.MergedAt = parseTimePtr(*raw.MergedAt) } if raw.ClosedAt != nil { pr.ClosedAt = parseTimePtr(*raw.ClosedAt) } return pr } func completePatRepositoryIdentity(owner, name, fullName string) (string, string) { parts := strings.SplitN(fullName, "/", 2) if len(parts) != 2 { return owner, name } if owner == "" { owner = parts[0] } if name == "" { name = parts[1] } return owner, name } func applyPatHeadRepository(pr *PR, repository *patRepository) { if repository == nil { return } pr.HeadRepoID = repository.ID pr.HeadRepoOwner, pr.HeadRepoName = completePatRepositoryIdentity( repository.Owner.Login, repository.Name, repository.FullName, ) pr.HeadRepoCloneURL = repository.CloneURL } func applyPatBaseRepository(pr *PR, repository *patRepository) { if repository == nil { return } pr.BaseRepoID = repository.ID pr.BaseRepoOwner, pr.BaseRepoName = completePatRepositoryIdentity( repository.Owner.Login, repository.Name, repository.FullName, ) pr.BaseDefaultBranch = repository.DefaultBranch } func convertPatIssue(raw *patIssue, owner, repo string) *Issue { labels := make([]string, len(raw.Labels)) for i, label := range raw.Labels { labels[i] = label.Name } assignees := make([]string, len(raw.Assignees)) for i, assignee := range raw.Assignees { assignees[i] = assignee.Login } issue := &Issue{ Number: raw.Number, Title: raw.Title, URL: raw.HTMLURL, HTMLURL: raw.HTMLURL, State: strings.ToLower(raw.State), Body: raw.Body, AuthorLogin: raw.User.Login, RepoOwner: owner, RepoName: repo, Labels: labels, Assignees: assignees, CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, } if raw.ClosedAt != nil { issue.ClosedAt = parseTimePtr(*raw.ClosedAt) } return issue } func convertPatRequestedReviewers(raw *patPR) []RequestedReviewer { reviewers := make([]RequestedReviewer, 0, len(raw.RequestedReviewers)+len(raw.RequestedTeams)) for _, reviewer := range raw.RequestedReviewers { if reviewer.Login == "" { continue } reviewers = append(reviewers, RequestedReviewer{Login: reviewer.Login, Type: reviewerTypeUser}) } for _, team := range raw.RequestedTeams { login := team.Slug if login == "" { login = team.Name } if login == "" { continue } reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam}) } return reviewers }