Files
kandev/apps/backend/internal/github/gh_client.go
T
2026-09-10 23:39:56 +01:00

1598 lines
55 KiB
Go

package github
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/kandev/kandev/internal/common/subproc"
)
// ghSearchReposPath is the GitHub REST search-repositories path that gh CLI
// invocations target via `gh api`. Centralised to keep the search-bucket
// rate-limit dispatch (`resourceForGHArgs`) and the repo search helpers in
// sync.
const ghSearchReposPath = "search/repositories"
const ghMergeableState = "MERGEABLE"
// ghAccessibleReposPath is the GET /user/repos endpoint (with affiliation +
// sort + per_page baked in) that backs ListAccessibleRepos. It returns a flat
// JSON array on the core REST quota, replacing the per-org search fan-out.
const ghAccessibleReposPathFmt = "/user/repos?affiliation=%s&sort=pushed&per_page=%d"
// GHClient implements Client using the gh CLI.
type GHClient struct {
rateTracker *RateTracker
mergePollWait func(context.Context, time.Duration) error
}
// NewGHClient creates a new gh CLI-based client.
func NewGHClient() *GHClient {
return &GHClient{}
}
// WithRateTracker attaches a rate tracker so stderr output is inspected for
// rate-limit signals after every gh invocation. Returns the client for
// chaining; safe to call before any commands run.
func (c *GHClient) WithRateTracker(t *RateTracker) *GHClient {
c.rateTracker = t
return c
}
// reGHRateLimit matches the prose gh prints when a request hit a primary or
// secondary rate limit. The exact text varies by gh version and locale, but
// "rate limit" / "API rate limit" appear consistently.
var ghRateLimitMarkers = []string{"rate limit", "abuse detection"}
func ghStderrIndicatesRateLimit(stderr string) bool {
if stderr == "" {
return false
}
lower := strings.ToLower(stderr)
for _, marker := range ghRateLimitMarkers {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// inspectRateStderr inspects the stderr from a failed `gh` invocation and
// flags the appropriate resource bucket as exhausted. `gh api <path>` is REST
// (Core) by default, with `api graphql` and `api search/*` as the documented
// exceptions; non-`api` subcommands like `pr`, `issue`, and `repo` are
// implemented against GraphQL by gh itself, so they map to the GraphQL bucket.
func (c *GHClient) inspectRateStderr(args []string, stderr string) {
if c.rateTracker == nil {
return
}
if !ghStderrIndicatesRateLimit(stderr) {
return
}
c.rateTracker.markRateExhausted(resourceForGHArgs(args), time.Time{})
}
// resourceForGHArgs maps a `gh` argv to the rate-limit bucket the call hits.
// Exposed at package scope so the table-driven test can pin every branch.
func resourceForGHArgs(args []string) Resource {
if len(args) == 0 {
return ResourceCore
}
if args[0] != "api" {
// `gh pr`, `gh issue`, `gh repo`, etc. are all GraphQL under the hood.
return ResourceGraphQL
}
if len(args) < 2 {
return ResourceCore
}
switch {
case args[1] == "graphql":
return ResourceGraphQL
case strings.HasPrefix(args[1], "search/"):
return ResourceSearch
default:
return ResourceCore
}
}
// GHAvailable checks if the gh CLI is installed and accessible.
func GHAvailable() bool {
_, err := exec.LookPath("gh")
return err == nil
}
func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) {
// Treat any non-zero exit as "not authenticated". This avoids parsing
// locale-dependent error messages and handles multi-account scenarios
// where a secondary account has an invalid token. GHAvailable() already
// guards binary existence before this method is called.
_, err := c.run(ctx, "auth", "status", "--hostname", "github.com")
if err == nil {
return true, nil
}
// Propagate timeout/cancellation errors so callers can distinguish them
// from a genuine "not authenticated" result. Check the returned error
// (not ctx.Err()) because run() may apply its own child deadline.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return false, err
}
return false, nil
}
// RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting.
func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
var stdout, stderr bytes.Buffer
runErr, execCtxErr := subproc.RunGHAfterAcquire(ctx, resolveGHExecTimeout(ctx), func(execCtx context.Context) *exec.Cmd {
cmd := exec.CommandContext(execCtx, "gh", "auth", "status", "--hostname", "github.com")
cmd.Stdout = &stdout
cmd.Stderr = &stderr
return cmd
})
exitCode := 0
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
output := stderr.String()
if output == "" {
output = stdout.String()
}
// When acquire/context fails before gh runs (or the exec is killed by
// the per-command deadline), both buffers can be empty. Surface the
// underlying error so the diagnostics panel doesn't show a blank
// "command failed with exit code -1" with no explanation.
if output == "" && runErr != nil {
if execCtxErr != nil {
output = fmt.Sprintf("gh auth status did not run: %v (context: %v)", runErr, execCtxErr)
} else {
output = fmt.Sprintf("gh auth status did not run: %v", runErr)
}
}
return &AuthDiagnostics{
Command: "gh auth status --hostname github.com",
Output: output,
ExitCode: exitCode,
}
}
func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
out, err := c.run(ctx, "api", "user", "-q", ".login")
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
return strings.TrimSpace(out), nil
}
// ghRequestedReviewer is a user/team requested reviewer returned by gh pr view.
type ghRequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Slug string `json:"slug"`
Name string `json:"name"`
}
// ghPR is the JSON shape returned by gh pr list/view.
type ghPR struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Body string `json:"body"`
HeadRefName string `json:"headRefName"`
HeadRefOid string `json:"headRefOid"`
BaseRefName string `json:"baseRefName"`
// IsDraft is a pointer: AC-12a requires distinguishing an omitted or
// null isDraft from a genuine false, which a plain bool can't after decode.
IsDraft *bool `json:"isDraft"`
Mergeable string `json:"mergeable"`
MergeStateStatus string `json:"mergeStateStatus"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
// ChangedFiles is a pointer for the same reason as IsDraft (AC-12a): 0 is
// a legitimate observation and must stay distinguishable from absent/null.
ChangedFiles *int `json:"changedFiles"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
MergedAt string `json:"mergedAt"`
ClosedAt string `json:"closedAt"`
ReviewRequests []ghRequestedReviewer `json:"reviewRequests"`
MaintainerCanModify bool `json:"maintainerCanModify"`
HeadRepository ghRepository `json:"headRepository"`
HeadRepositoryOwner ghRepositoryOwner `json:"headRepositoryOwner"`
Author struct {
Login string `json:"login"`
} `json:"author"`
// MergedBy decodes to a zero-value Login on gh's `null` for an unmerged
// PR — never a placeholder value.
MergedBy struct {
Login string `json:"login"`
} `json:"mergedBy"`
// AutoMergeRequest is a pointer: gh returns null when auto-merge was
// never armed. Any non-nil value means "armed at fetch time" — never
// "merged by auto-merge" (auto_merge is cleared once it fires).
AutoMergeRequest *struct {
EnabledAt string `json:"enabledAt"`
} `json:"autoMergeRequest"`
}
type ghRepository struct {
DefaultBranch string `json:"defaultBranch"`
ID string `json:"id"`
Name string `json:"name"`
NameWithOwner string `json:"nameWithOwner"`
URL string `json:"url"`
CloneURL string `json:"cloneUrl"`
HTTPSURL string `json:"httpsUrl"`
}
type ghRepositoryOwner struct {
Login string `json:"login"`
}
type ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// The gh CLI currently emits an empty string for open issues.
ClosedAt string `json:"closedAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Assignees []struct {
Login string `json:"login"`
} `json:"assignees"`
}
func (c *GHClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
out, err := c.run(ctx, "pr", "view", fmt.Sprintf("%d", number),
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--json", "number,title,url,state,body,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,mergeStateStatus,additions,deletions,changedFiles,mergedBy,autoMergeRequest,createdAt,updatedAt,mergedAt,closedAt,reviewRequests,maintainerCanModify,headRepository,headRepositoryOwner")
if err != nil {
if isNotFoundErr(err) {
return nil, &GitHubAPIError{
StatusCode: http.StatusNotFound,
Endpoint: fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number),
Body: err.Error(),
}
}
return nil, fmt.Errorf("get PR #%d: %w", number, err)
}
var raw ghPR
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("parse PR response: %w", err)
}
return convertGHPR(&raw, owner, repo), nil
}
func (c *GHClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
out, err := c.run(ctx, "issue", "view", fmt.Sprintf("%d", number),
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--json", "number,title,url,state,body,author,labels,assignees,createdAt,updatedAt,closedAt")
if err != nil {
if isNotFoundErr(err) {
return nil, &GitHubAPIError{
StatusCode: http.StatusNotFound,
Endpoint: fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number),
Body: err.Error(),
}
}
return nil, fmt.Errorf("get issue #%d: %w", number, err)
}
var raw ghIssue
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("parse issue response: %w", err)
}
return convertGHIssue(&raw, owner, repo), nil
}
func (c *GHClient) FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) {
return c.findPRByHead(ctx, owner, repo, branch, branch, "", "")
}
func (c *GHClient) FindPRByHead(ctx context.Context, owner, repo, headOwner, headRepo, branch string) (*PR, error) {
return c.findPRByHead(ctx, owner, repo, branch, branch, headOwner, headRepo)
}
func (c *GHClient) findPRByHead(ctx context.Context, owner, repo, head, branchForError, headOwner, headRepo string) (*PR, error) {
limit := "1"
if headOwner != "" {
// --head accepts only a branch name. Fetch enough matches to select the
// requested fork after gh returns PRs from the whole fork network.
limit = "100"
}
out, err := c.run(ctx, "pr", "list",
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--head", head,
"--state", "open",
"--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,mergeStateStatus,additions,deletions,createdAt,updatedAt,headRepository,headRepositoryOwner",
"--limit", limit)
if err != nil {
return nil, fmt.Errorf("find PR by branch %q: %w", branchForError, err)
}
var prs []ghPR
if err := json.Unmarshal([]byte(out), &prs); err != nil {
return nil, fmt.Errorf("parse PR list: %w", err)
}
for i := range prs {
pr := convertGHPR(&prs[i], owner, repo)
if (headOwner == "" && headRepo == "") ||
sameRepositoryIdentity(pr.HeadRepoOwner, pr.HeadRepoName, headOwner, headRepo) {
return pr, nil
}
}
return nil, nil
}
func (c *GHClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) {
out, err := c.run(ctx, "pr", "list",
"--repo", fmt.Sprintf("%s/%s", owner, repo),
"--author", "@me",
"--state", "open",
"--json", "number,title,url,state,headRefName,headRefOid,baseRefName,author,isDraft,mergeable,mergeStateStatus,additions,deletions,createdAt,updatedAt")
if err != nil {
return nil, fmt.Errorf("list authored PRs: %w", err)
}
return c.parsePRList(out, owner, repo)
}
func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) {
query := buildReviewSearchQuery(scope, filter, customQuery)
out, err := c.run(ctx, "api", "search/issues",
"-X", "GET",
"-f", "q="+query,
"-f", "per_page=50",
"--jq", ".items")
if err != nil {
return nil, fmt.Errorf("list review-requested PRs: %w", err)
}
return c.parseSearchResults(out)
}
func (c *GHClient) 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 *GHClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildPRSearchQuery(filter, customQuery)
out, err := c.run(ctx, "api", "search/issues",
"-X", "GET",
"-f", "q="+query,
"-f", fmt.Sprintf("per_page=%d", perPage),
"-f", fmt.Sprintf("page=%d", page),
)
if err != nil {
return nil, fmt.Errorf("search PRs: %w", err)
}
var result struct {
TotalCount int `json:"total_count"`
Items json.RawMessage `json:"items"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
return nil, fmt.Errorf("parse PR search response: %w", err)
}
prs, err := c.parseSearchResults(string(result.Items))
if err != nil {
return nil, err
}
return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
}
func (c *GHClient) 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 *GHClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildIssueSearchQuery(filter, customQuery)
out, err := c.run(ctx, "api", "search/issues",
"-X", "GET",
"-f", "q="+query,
"-f", fmt.Sprintf("per_page=%d", perPage),
"-f", fmt.Sprintf("page=%d", page),
)
if err != nil {
return nil, fmt.Errorf("list issues: %w", err)
}
var result struct {
TotalCount int `json:"total_count"`
Items []issueSearchItem `json:"items"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
return nil, fmt.Errorf("parse issue search response: %w", err)
}
issues := parseIssueSearchResults(result.Items)
return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil
}
func (c *GHClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number),
"--jq", ".state")
if err != nil {
return "", fmt.Errorf("get issue state: %w", err)
}
return strings.TrimSpace(out), nil
}
func (c *GHClient) ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) {
out, err := c.run(ctx, "api", "user/orgs", "--paginate")
if err != nil {
return nil, fmt.Errorf("list user orgs: %w", err)
}
var raw []struct {
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("parse 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 *GHClient) SearchOrgRepos(ctx context.Context, org, query string, limit int) ([]GitHubRepo, error) {
q := "org:" + org
if query != "" {
q += " " + query
}
limit = clampRepoSearchLimit(limit)
out, err := c.run(ctx, "api", ghSearchReposPath,
"-X", "GET",
"-f", "q="+q,
"-f", fmt.Sprintf("per_page=%d", limit),
"--jq", ".items")
if err != nil {
return nil, fmt.Errorf("search org repos: %w", err)
}
return parseGHSearchRepos(out)
}
func (c *GHClient) ListUserRepos(ctx context.Context, query string, limit int) ([]GitHubRepo, error) {
limit = clampRepoSearchLimit(limit)
login, err := c.GetAuthenticatedUser(ctx)
if err != nil {
return nil, fmt.Errorf("list user repos: %w", err)
}
args := buildUserReposGHArgs(login, query, limit)
out, err := c.run(ctx, args...)
if err != nil {
return nil, fmt.Errorf("list user repos: %w", err)
}
return parseGHSearchRepos(out)
}
// buildUserReposGHArgs constructs the `gh api search/repositories` argv used
// to list repos for the authenticated user. Keeping this pure makes the
// endpoint + qualifier construction unit-testable without spawning gh. The
// `q` value mirrors the PAT path (`user:<login> <query>`) so both clients
// honour the same GitHub search syntax (e.g. `language:go`, `in:name`).
// Callers must clamp `limit` via clampRepoSearchLimit beforehand.
func buildUserReposGHArgs(login, query string, limit int) []string {
q := "user:" + login
if query != "" {
q += " " + query
}
return []string{
"api", ghSearchReposPath,
"-X", "GET",
"-f", "q=" + q,
"-f", fmt.Sprintf("per_page=%d", limit),
"--jq", ".items",
}
}
// ListAccessibleRepos lists every repo the authenticated user can access via a
// single `gh api /user/repos` call on the core REST quota. The endpoint returns
// a flat JSON array (parsed by parseGHSearchRepos, which already decodes a
// top-level array). No --paginate: one page of up to 100 is what the picker
// needs (the frontend caps at 100 and filters client-side).
//
// query is a BEST-EFFORT substring filter over only that single un-paginated
// page — a match beyond the cap returns nothing. The frontend does the
// canonical client-side filtering, so this server filter is just optional
// narrowing; do not rely on it for completeness.
func (c *GHClient) ListAccessibleRepos(ctx context.Context, query string, limit int) ([]GitHubRepo, error) {
limit = clampRepoSearchLimit(limit)
out, err := c.run(ctx, buildAccessibleReposGHArgs(limit)...)
if err != nil {
return nil, fmt.Errorf("list accessible repos: %w", err)
}
repos, err := parseGHSearchRepos(out)
if err != nil {
return nil, err
}
return filterReposByQuery(repos, query), nil
}
// HasRepositoryAccess probes the repository root through the selected gh
// account. It does not rely on the first page returned by /user/repos.
func (c *GHClient) HasRepositoryAccess(ctx context.Context, owner, repo string) (bool, error) {
out, err := c.run(ctx, "api", fmt.Sprintf("/repos/%s/%s", owner, repo), "--jq", ".full_name")
if err != nil {
return false, err
}
return strings.TrimSpace(out) != "", nil
}
// GetRepository returns the selected gh account's repository identity and
// permissions for managed contribution-destination preparation.
func (c *GHClient) GetRepository(ctx context.Context, owner, repo string) (*GitHubRepository, error) {
out, err := c.run(ctx, "api", fmt.Sprintf("/repos/%s/%s", owner, repo))
if err != nil {
if isNotFoundErr(err) {
return nil, fmt.Errorf("get repository %s/%s: %w", owner, repo, &GitHubAPIError{
StatusCode: http.StatusNotFound,
Endpoint: fmt.Sprintf("/repos/%s/%s", owner, repo),
Body: err.Error(),
})
}
return nil, fmt.Errorf("get repository %s/%s: %w", owner, repo, err)
}
var raw githubRepositoryResponse
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("decode repository %s/%s: %w", owner, repo, err)
}
return projectGitHubRepository(raw), nil
}
// ListRepositoryForks lists the canonical repository's fork network. The
// network is authoritative for finding an existing fork whose name changed
// after creation, so callers must not assume the canonical repository name.
func (c *GHClient) ListRepositoryForks(ctx context.Context, owner, repo string) ([]*GitHubRepository, error) {
out, err := c.run(ctx, "api", fmt.Sprintf("repos/%s/%s/forks?per_page=100", owner, repo), "--paginate", "--slurp")
if err != nil {
return nil, fmt.Errorf("list repository forks for %s/%s: %w", owner, repo, err)
}
var pages [][]githubRepositoryResponse
if err := json.Unmarshal([]byte(out), &pages); err != nil {
return nil, fmt.Errorf("decode repository forks for %s/%s: %w", owner, repo, err)
}
forks := make([]*GitHubRepository, 0)
for _, page := range pages {
for _, raw := range page {
forks = append(forks, projectGitHubRepository(raw))
}
}
return forks, nil
}
// CreateFork creates a fork for the selected named gh account. The service
// resolver verifies the returned identity after GitHub finishes provisioning.
func (c *GHClient) CreateFork(ctx context.Context, owner, repo string) (*GitHubRepository, error) {
args := []string{"api", fmt.Sprintf("/repos/%s/%s/forks", owner, repo), "-X", "POST", "--input", "-"}
out, err := c.runWithStdin(ctx, []byte(`{}`), args...)
if err != nil {
return nil, fmt.Errorf("create fork for %s/%s: %w", owner, repo, err)
}
var raw githubRepositoryResponse
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("decode created fork for %s/%s: %w", owner, repo, err)
}
return projectGitHubRepository(raw), nil
}
// buildAccessibleReposGHArgs constructs the `gh api /user/repos?...` argv for
// ListAccessibleRepos. Keeping it pure makes the endpoint/query construction
// unit-testable without spawning gh. Callers must clamp `limit` via
// clampRepoSearchLimit beforehand.
func buildAccessibleReposGHArgs(limit int) []string {
endpoint := fmt.Sprintf(ghAccessibleReposPathFmt, accessibleReposAffiliation, limit)
return []string{"api", endpoint}
}
func parseGHSearchRepos(data string) ([]GitHubRepo, error) {
var 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"`
}
if err := json.Unmarshal([]byte(data), &items); err != nil {
return nil, fmt.Errorf("parse search repos: %w", err)
}
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, nil
}
// ghReview is the JSON shape for reviews from gh pr view.
type ghReview struct {
ID int64 `json:"id"`
Author struct {
Login string `json:"login"`
} `json:"author"`
User struct {
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
} `json:"user"`
State string `json:"state"`
Body string `json:"body"`
SubmittedAt time.Time `json:"submitted_at"`
}
func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number),
"--paginate")
if err != nil {
return nil, fmt.Errorf("list PR reviews: %w", err)
}
var raw []ghReview
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("parse reviews: %w", err)
}
reviews := make([]PRReview, len(raw))
for i, r := range raw {
author := r.Author.Login
avatar := ""
if r.User.Login != "" {
author = r.User.Login
avatar = r.User.AvatarURL
}
reviews[i] = PRReview{
ID: r.ID,
Author: author,
AuthorAvatar: avatar,
State: r.State,
Body: r.Body,
CreatedAt: r.SubmittedAt,
}
}
return reviews, nil
}
// ghComment is the JSON shape for review comments from the GitHub API.
type ghComment struct {
ID int64 `json:"id"`
HTMLURL string `json:"html_url"`
Path string `json:"path"`
Line int `json:"line"`
Side string `json:"side"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
InReplyTo *int64 `json:"in_reply_to_id"`
User struct {
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
Type string `json:"type"`
} `json:"user"`
}
func (c *GHClient) ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) {
reviewEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number), since)
reviewOut, err := c.run(ctx, "api", reviewEndpoint, "--paginate")
if err != nil {
return nil, fmt.Errorf("list PR comments: %w", err)
}
var reviewRaw []ghComment
if err := json.Unmarshal([]byte(reviewOut), &reviewRaw); err != nil {
return nil, fmt.Errorf("parse comments: %w", err)
}
issueEndpoint := appendSinceQuery(fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), since)
issueOut, err := c.run(ctx, "api", issueEndpoint, "--paginate")
if err != nil {
return nil, fmt.Errorf("list issue comments: %w", err)
}
var issueRaw []ghIssueComment
if err := json.Unmarshal([]byte(issueOut), &issueRaw); err != nil {
return nil, fmt.Errorf("parse issue comments: %w", err)
}
return mergeAndSortPRComments(convertRawComments(reviewRaw), convertRawIssueComments(issueRaw)), nil
}
// ghCheckRun is the JSON shape from the check-runs API.
type ghCheckRun struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion *string `json:"conclusion"`
HTMLURL string `json:"html_url"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
Output struct {
Title *string `json:"title"`
Summary *string `json:"summary"`
} `json:"output"`
}
func (c *GHClient) ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) {
checkRunsOut, err := c.run(ctx, "api",
"--paginate",
fmt.Sprintf("repos/%s/%s/commits/%s/check-runs?per_page=100", owner, repo, ref),
"--jq", ".check_runs[]")
if err != nil {
return nil, fmt.Errorf("list check runs: %w", err)
}
checkRunsRaw, err := decodeGHCheckRuns(checkRunsOut)
if err != nil {
return nil, fmt.Errorf("parse check runs: %w", err)
}
statusOut, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/commits/%s/status", owner, repo, ref),
"--jq", ".statuses")
if err != nil {
return nil, fmt.Errorf("list status contexts: %w", err)
}
var statusRaw []ghStatusContext
if err := json.Unmarshal([]byte(statusOut), &statusRaw); err != nil {
return nil, fmt.Errorf("parse status contexts: %w", err)
}
return mergeChecks(convertRawCheckRuns(checkRunsRaw), convertRawStatusContexts(statusRaw)), nil
}
func (c *GHClient) ListWorkflowRuns(ctx context.Context, owner, repo, headSHA string) ([]WorkflowRun, error) {
runsOut, err := c.run(ctx, "api", "--paginate",
fmt.Sprintf("repos/%s/%s/actions/runs?head_sha=%s&per_page=100", owner, repo, url.QueryEscape(headSHA)),
"--jq", ".workflow_runs[]")
if err != nil {
return nil, fmt.Errorf("list workflow runs: %w", err)
}
runsRaw, err := decodeGHWorkflowRuns(runsOut)
if err != nil {
return nil, fmt.Errorf("parse workflow runs: %w", err)
}
runs := make([]WorkflowRun, 0, len(runsRaw))
for _, raw := range runsRaw {
runs = append(runs, convertRawWorkflowRun(raw))
}
return runs, nil
}
func (c *GHClient) ListWorkflowRunJobs(ctx context.Context, owner, repo string, runID int64, attempt int) ([]WorkflowJob, error) {
endpoint := fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs?per_page=100", owner, repo, runID)
if attempt > 0 {
endpoint = fmt.Sprintf("repos/%s/%s/actions/runs/%d/attempts/%d/jobs?per_page=100", owner, repo, runID, attempt)
}
jobsOut, err := c.run(ctx, "api", "--paginate", endpoint, "--jq", ".jobs[]")
if err != nil {
return nil, fmt.Errorf("list workflow jobs: %w", err)
}
jobsRaw, err := decodeGHWorkflowJobs(jobsOut)
if err != nil {
return nil, fmt.Errorf("parse workflow jobs: %w", err)
}
return convertRawWorkflowJobs(jobsRaw), nil
}
// decodeGHCheckRuns decodes whitespace-separated JSON check-run objects
// emitted by gh --paginate --jq '.check_runs[]'.
func decodeGHCheckRuns(out string) ([]ghCheckRun, error) {
if strings.TrimSpace(out) == "" {
return nil, nil
}
dec := json.NewDecoder(strings.NewReader(out))
var checkRuns []ghCheckRun
for {
var checkRun ghCheckRun
if err := dec.Decode(&checkRun); err != nil {
if errors.Is(err, io.EOF) {
return checkRuns, nil
}
return nil, err
}
checkRuns = append(checkRuns, checkRun)
}
}
func (c *GHClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
return getPRFeedback(ctx, c, owner, repo, number)
}
func (c *GHClient) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
return getPRStatus(ctx, c, owner, repo, number)
}
// FetchBranchProtection looks up branch protection via `gh api`. A 404 from
// `gh` (no rule) and a 403 (token lacks Administration: Read scope) are both
// treated as "no rule we can see" so callers cache the negative result and
// don't burn rate-limit quota on every poll. The string match is permissive
// across formats so a future `gh` CLI release that tweaks the wording
// doesn't silently break the cache.
func (c *GHClient) FetchBranchProtection(ctx context.Context, owner, repo, branch string) (BranchProtection, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/branches/%s/protection", owner, repo, branch))
if err != nil {
if isNotFoundErr(err) || isForbiddenErr(err) {
return BranchProtection{HasRule: false}, nil
}
return BranchProtection{}, err
}
var raw struct {
RequiredPullRequestReviews *struct {
RequiredApprovingReviewCount int `json:"required_approving_review_count"`
} `json:"required_pull_request_reviews"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return BranchProtection{}, fmt.Errorf("decode branch protection: %w", err)
}
if raw.RequiredPullRequestReviews == nil {
return BranchProtection{HasRule: true}, nil
}
return BranchProtection{
HasRule: true,
RequiredApprovingReviewCount: raw.RequiredPullRequestReviews.RequiredApprovingReviewCount,
}, nil
}
// isNotFoundErr matches the formats the `gh` CLI uses to report a 404 across
// recent versions: "HTTP 404", "HTTP 404: ...", and "404 Not Found".
// Restrictive enough that an unrelated 404 substring (e.g. inside a JSON
// payload accidentally rendered into stderr) is unlikely to false-match.
func isNotFoundErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "HTTP 404") ||
strings.Contains(s, "404 Not Found") ||
strings.Contains(s, "status: 404")
}
// isForbiddenErr matches the formats the `gh` CLI uses to report a 403, used
// by the branch-protection lookup to silently fall back to "no rule" when
// the token lacks Administration: Read scope.
func isForbiddenErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "HTTP 403") ||
strings.Contains(s, "403 Forbidden") ||
strings.Contains(s, "status: 403")
}
// isUnauthorizedErr matches the formats the `gh` CLI uses to report a 401
// (expired or revoked credential).
func isUnauthorizedErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "HTTP 401") ||
strings.Contains(s, "401 Unauthorized") ||
strings.Contains(s, "status: 401")
}
// isRateLimitedErr matches the formats the `gh` CLI uses to report a 429.
func isRateLimitedErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "HTTP 429") ||
strings.Contains(s, "429 Too Many Requests") ||
strings.Contains(s, "status: 429")
}
// ghServerErrPattern extracts a 5xx status from gh CLI stderr. Unlike the
// single-status matchers above, a server error can be any code in the range,
// so this is a pattern rather than an enumeration; every 5xx classifies the
// same way (config sync's ErrUnavailable), so the exact code only matters for
// diagnostics.
var ghServerErrPattern = regexp.MustCompile(`(?:HTTP |status: )(5\d\d)\b`)
func ghServerErrStatusCode(err error) (int, bool) {
if err == nil {
return 0, false
}
m := ghServerErrPattern.FindStringSubmatch(err.Error())
if m == nil {
return 0, false
}
code, convErr := strconv.Atoi(m[1])
if convErr != nil {
return 0, false
}
return code, true
}
// ghClassifyContentsErr promotes a gh CLI repo-contents failure to a
// *GitHubAPIError for every status config sync's fetch-error classification
// needs to see: 404 (existing, unchanged), 401, 403, 429, and any 5xx.
// Returns nil when the error matches none of those, so the caller falls back
// to a bare wrapped error — which is exactly right, since an unclassified
// status belongs in the residue class by exclusion, not by a guess here.
func ghClassifyContentsErr(err error, endpoint string) *GitHubAPIError {
switch {
case isNotFoundErr(err):
return &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: endpoint, Body: err.Error()}
case isUnauthorizedErr(err):
return &GitHubAPIError{StatusCode: http.StatusUnauthorized, Endpoint: endpoint, Body: err.Error()}
case isForbiddenErr(err):
return &GitHubAPIError{StatusCode: http.StatusForbidden, Endpoint: endpoint, Body: err.Error()}
case isRateLimitedErr(err):
return &GitHubAPIError{StatusCode: http.StatusTooManyRequests, Endpoint: endpoint, Body: err.Error()}
default:
if code, ok := ghServerErrStatusCode(err); ok {
return &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: err.Error()}
}
return nil
}
}
func (c *GHClient) ListPRFiles(ctx context.Context, owner, repo string, number int) ([]PRFile, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/pulls/%d/files", owner, repo, number),
"--paginate")
if err != nil {
return nil, fmt.Errorf("list PR files: %w", err)
}
return parsePRFilesJSON(out)
}
func (c *GHClient) ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/pulls/%d/commits", owner, repo, number),
"--paginate")
if err != nil {
return nil, fmt.Errorf("list PR commits: %w", err)
}
return parsePRCommitsJSON(out)
}
func (c *GHClient) GetPRCommitDetail(ctx context.Context, owner, repo, sha string) (PRCommitDetail, error) {
if err := validateGitHubCommitSHA(sha); err != nil {
return PRCommitDetail{}, err
}
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/commits/%s?per_page=100", owner, repo, sha),
"--paginate", "--slurp")
if err != nil {
return PRCommitDetail{}, fmt.Errorf("get PR commit detail: %w", err)
}
detail, err := parsePRCommitDetailJSON(out)
if err != nil {
return PRCommitDetail{}, err
}
return detail, nil
}
func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
args := []string{"api",
fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number),
"-X", "POST",
"-f", "event=" + event,
}
if body != "" {
args = append(args, "-f", "body="+body)
}
_, err := c.run(ctx, args...)
if err != nil {
return fmt.Errorf("submit review on PR #%d: %w", number, err)
}
return nil
}
func (c *GHClient) RequestReviewers(ctx context.Context, owner, repo string, number int, reviewers []string) error {
args := []string{"api", fmt.Sprintf("repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, number), "-X", "POST"}
for _, reviewer := range reviewers {
args = append(args, "-f", "reviewers[]="+reviewer)
}
if _, err := c.run(ctx, args...); err != nil {
return fmt.Errorf("request reviewers on PR #%d: %w", number, err)
}
return nil
}
func (c *GHClient) 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)
args := []string{"api", endpoint, "-X", "PUT", "-f", "merge_action=default"}
if request.MergeMethod != "" {
args = append(args, "-f", "merge_method="+request.MergeMethod)
}
if request.ExpectedHeadSHA != "" {
args = append(args, "-f", "sha="+request.ExpectedHeadSHA)
}
out, err := c.run(ctx, args...)
response, err := decodeInitialMergeAsyncResponse(endpoint, number, out, err)
if err != nil {
return "", err
}
response, err = c.pollMergeAsyncResponse(ctx, endpoint, number, response)
if err != nil {
return "", err
}
if response.Status == mergeStatusFailed {
return "", newMergeFailureError(response.Details)
}
return normalizeMergeOutcome(response.Status)
}
func decodeInitialMergeAsyncResponse(endpoint string, number int, out string, runErr error) (mergeAsyncResponse, error) {
conflictBody := false
if runErr != nil {
// Surface status-based errors as GitHubAPIError so httpMergePR can
// translate merge rejections for gh CLI users, matching the PAT path.
if code, ok := ghMergeStatusCode(runErr); ok {
if code != http.StatusConflict {
return mergeAsyncResponse{}, &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: runErr.Error()}
}
out = runErr.Error()
conflictBody = true
} else {
return mergeAsyncResponse{}, fmt.Errorf("merge PR #%d: %w", number, runErr)
}
}
jsonBody := out
if start := strings.Index(jsonBody, "{"); start >= 0 {
if end := strings.LastIndex(jsonBody, "}"); end >= start {
jsonBody = jsonBody[start : end+1]
}
} else if conflictBody {
return mergeAsyncResponse{}, &GitHubAPIError{StatusCode: http.StatusConflict, Endpoint: endpoint, Body: out}
}
var response mergeAsyncResponse
if unmarshalErr := json.Unmarshal([]byte(jsonBody), &response); unmarshalErr != nil {
return mergeAsyncResponse{}, fmt.Errorf("decode GitHub merge response: %w", unmarshalErr)
}
return response, nil
}
func (c *GHClient) pollMergeAsyncResponse(
ctx context.Context,
endpoint string,
number int,
response mergeAsyncResponse,
) (mergeAsyncResponse, error) {
for response.Status == "pending" {
if response.Details.UUID == "" {
return mergeAsyncResponse{}, 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 mergeAsyncResponse{}, fmt.Errorf("wait to poll merge PR #%d: %w", number, waitErr)
}
result, runErr := c.run(ctx, "api", endpoint+"/"+response.Details.UUID)
if runErr != nil {
return mergeAsyncResponse{}, fmt.Errorf("poll merge PR #%d: %w", number, runErr)
}
if unmarshalErr := json.Unmarshal([]byte(result), &response); unmarshalErr != nil {
return mergeAsyncResponse{}, fmt.Errorf("decode GitHub merge response: %w", unmarshalErr)
}
}
return response, nil
}
// ghMergeStatusCode extracts the HTTP status code from a gh CLI merge error.
// Returns the code and true when the stderr matches one of GitHub's
// merge-API failure shapes (404/403/405/409); false otherwise so the caller
// falls back to a generic wrap.
func ghMergeStatusCode(err error) (int, bool) {
if err == nil {
return 0, false
}
if isNotFoundErr(err) {
return http.StatusNotFound, true
}
if isForbiddenErr(err) {
return http.StatusForbidden, true
}
s := err.Error()
if strings.Contains(s, "HTTP 400") || strings.Contains(s, "status: 400") ||
strings.Contains(s, "400 Bad Request") {
return http.StatusBadRequest, true
}
if strings.Contains(s, "HTTP 405") || strings.Contains(s, "status: 405") ||
strings.Contains(s, "405 Method Not Allowed") {
return http.StatusMethodNotAllowed, true
}
if strings.Contains(s, "HTTP 409") || strings.Contains(s, "status: 409") ||
strings.Contains(s, "409 Conflict") {
return http.StatusConflict, true
}
return 0, false
}
func (c *GHClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
out, err := c.run(ctx, "api", fmt.Sprintf("repos/%s/%s", owner, repo))
if err != nil {
return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %w", err)
}
var raw struct {
AllowMergeCommit *bool `json:"allow_merge_commit"`
AllowSquashMerge *bool `json:"allow_squash_merge"`
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return RepoMergeMethods{}, fmt.Errorf("parse repo: %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. Callers that fall back to GitHub's default on an
// empty pick get a meaningful error instead of a wrong-method 405.
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 *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/branches", owner, repo),
"-X", "GET",
"-f", "per_page=100",
"--paginate",
"--jq", ".[].name")
if err != nil {
return nil, fmt.Errorf("list repo branches: %w", err)
}
var branches []RepoBranch
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
name := strings.TrimSpace(line)
if name != "" {
branches = append(branches, RepoBranch{Name: name})
}
}
return branches, nil
}
func (c *GHClient) 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)
}
args := []string{"api", "gists",
"-X", "POST",
"-H", "Accept: " + githubAccept,
"--input", "-",
}
out, err := c.runWithStdin(ctx, body, args...)
if err != nil {
return nil, fmt.Errorf("create gist: %w", err)
}
var resp GistResponse
if err := json.Unmarshal([]byte(out), &resp); err != nil {
return nil, fmt.Errorf("decode gist response: %w", err)
}
return &resp, nil
}
func (c *GHClient) DeleteGist(ctx context.Context, gistID string) error {
if gistID == "" {
return fmt.Errorf("delete gist: empty id")
}
_, err := c.run(ctx, "api", "gists/"+gistID, "-X", "DELETE")
if err != nil {
// gh exits non-zero for HTTP errors; stderr contains the status
// line (e.g. "HTTP 404: Not Found"). Promote 404 to *GitHubAPIError
// so share.IsAlreadyGone matches consistently — PATClient.delete()
// already returns a typed error for the same case, and the share
// service uses errors.As to detect "gist already revoked upstream"
// and treat it as a soft success rather than a 502. Use isNotFoundErr
// so every gh-CLI 404 format ("HTTP 404", "404 Not Found", "status: 404")
// is recognised, not just the most common one.
if isNotFoundErr(err) {
return &GitHubAPIError{
StatusCode: http.StatusNotFound,
Endpoint: "/gists/" + gistID,
Body: err.Error(),
}
}
return fmt.Errorf("delete gist %s: %w", gistID, err)
}
return nil
}
// ListRepoDirectory lists the entries of a directory in a repository at the
// given ref via `gh api repos/{owner}/{repo}/contents/{path}`. 404 (missing
// directory), 401, 403, 429, and any 5xx are promoted to a *GitHubAPIError,
// mirroring PATClient.
func (c *GHClient) ListRepoDirectory(ctx context.Context, owner, repo, dir, ref string) ([]RepoContentEntry, error) {
args := ghContentsArgs(owner, repo, dir, ref)
out, err := c.run(ctx, args...)
if err != nil {
endpoint := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, repoContentsPath(dir))
if apiErr := ghClassifyContentsErr(err, endpoint); apiErr != nil {
return nil, apiErr
}
return nil, fmt.Errorf("list repo directory: %w", err)
}
var entries []RepoContentEntry
if err := json.Unmarshal([]byte(out), &entries); err != nil {
return nil, fmt.Errorf("list repo directory: decode: %w", err)
}
return entries, nil
}
// GetRepoFileContent fetches the raw decoded content of a single file via
// `gh api repos/{owner}/{repo}/contents/{path}`. 404 (missing file), 401,
// 403, 429, and any 5xx are promoted to a *GitHubAPIError, mirroring
// PATClient.
func (c *GHClient) GetRepoFileContent(ctx context.Context, owner, repo, path, ref string) ([]byte, error) {
args := ghContentsArgs(owner, repo, path, ref)
out, err := c.run(ctx, args...)
if err != nil {
endpoint := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, repoContentsPath(path))
if apiErr := ghClassifyContentsErr(err, endpoint); apiErr != nil {
return nil, apiErr
}
return nil, fmt.Errorf("get repo file content: %w", err)
}
var raw ghRepoFileContent
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("get repo file content: decode: %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
}
// ghContentsArgs builds the `gh api` argv for the repo contents endpoint,
// shared by ListRepoDirectory and GetRepoFileContent. ref, when non-empty, is
// passed as a `-f` field (requiring the explicit `-X GET`, since `gh api`
// otherwise switches to POST once any `-f`/`-F` field is supplied).
func ghContentsArgs(owner, repo, path, ref string) []string {
endpoint := "repos/" + owner + "/" + repo + "/contents"
if p := repoContentsPath(path); p != "" {
endpoint += "/" + p
}
args := []string{"api", endpoint, "-X", "GET"}
if ref != "" {
args = append(args, "-f", "ref="+ref)
}
return args
}
const ghCLITimeout = 30 * time.Second
// resolveGHExecTimeout returns the per-command exec budget used by
// run / runWithStdin. Honours the caller's ctx deadline when set (so a
// short-lived WS request stays bounded by the request deadline), but
// caps the budget at ghCLITimeout when the deadline is further out or
// absent. Used by the acquire-then-derive-timeout path so a queued
// waiter's exec timer starts AFTER it gets the throttle slot.
func resolveGHExecTimeout(ctx context.Context) time.Duration {
if dl, ok := ctx.Deadline(); ok {
remaining := time.Until(dl)
if remaining < ghCLITimeout {
return remaining
}
}
return ghCLITimeout
}
// run executes a gh CLI command and returns its stdout output.
// Stderr is captured separately to avoid contaminating JSON output.
// A 30s per-command exec timeout is applied AFTER the gh throttle slot
// is acquired (acquire-then-derive-timeout). Pre-fix the timer started
// before Acquire, so a queued waiter inherited a deadline that had
// already partly elapsed against throttle queue time — producing the
// `signal: killed` + `context deadline exceeded` cascade in the
// SyncWatchesBatched storm logs.
func (c *GHClient) run(ctx context.Context, args ...string) (string, error) {
return c.runGH(ctx, nil, args...)
}
// runWithStdin is like run but pipes stdin into the gh CLI process.
// Useful for `gh api --input -` calls where the body comes from JSON.
func (c *GHClient) runWithStdin(ctx context.Context, stdin []byte, args ...string) (string, error) {
return c.runGH(ctx, stdin, args...)
}
// runGH is the shared body of run / runWithStdin — both apply the same
// acquire-then-derive-timeout ordering, the same stderr/rate-limit
// inspection, and the same error wrap. The only delta is whether stdin
// is wired (`stdin != nil`).
func (c *GHClient) runGH(ctx context.Context, stdin []byte, args ...string) (string, error) {
var stdout, stderr bytes.Buffer
execTimeout := resolveGHExecTimeout(ctx)
runErr, execCtxErr := subproc.RunGHAfterAcquire(ctx, execTimeout, func(execCtx context.Context) *exec.Cmd {
cmd := exec.CommandContext(execCtx, "gh", args...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
cmd.Stdout = &stdout
cmd.Stderr = &stderr
return cmd
})
if runErr != nil {
// Surface execCtx timeouts AND cancellations as the canonical
// context error so callers (FetchRateLimit, IsAuthenticated,
// share.IsAlreadyGone) can still errors.Is(err, context.X). The
// runErr from cmd.Run on a killed child is `signal: killed`,
// which loses both classifier signals that pre-fix code relied on.
if execCtxErr != nil && (errors.Is(execCtxErr, context.DeadlineExceeded) || errors.Is(execCtxErr, context.Canceled)) {
return stdout.String(), fmt.Errorf("gh %s: %w", firstArg(args), execCtxErr)
}
c.inspectRateStderr(args, stderr.String())
return stdout.String(), fmt.Errorf("gh %s: %w: %s", firstArg(args), runErr, stderr.String())
}
return stdout.String(), nil
}
// firstArg returns args[0] when present, else "<no-args>". Used for error
// messages when the semaphore acquisition fails before we even exec gh.
func firstArg(args []string) string {
if len(args) == 0 {
return "<no-args>"
}
return args[0]
}
// FetchRateLimit calls `gh api rate_limit` and seeds the tracker with the
// returned snapshots. Best-effort: a failure (e.g. CLI absent, network) is
// logged and ignored.
func (c *GHClient) FetchRateLimit(ctx context.Context) error {
if c.rateTracker == nil {
return nil
}
out, err := c.run(ctx, "api", "rate_limit")
if err != nil {
return fmt.Errorf("fetch rate limit: %w", err)
}
raw, err := decodeRateLimitResponse([]byte(out))
if err != nil {
return fmt.Errorf("parse rate_limit response: %w", err)
}
recordRateLimitResources(c.rateTracker, raw.Resources)
return nil
}
func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) {
var raw []ghPR
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return nil, fmt.Errorf("parse PR list: %w", err)
}
prs := make([]*PR, len(raw))
for i := range raw {
prs[i] = convertGHPR(&raw[i], owner, repo)
}
return prs, nil
}
// ghSearchItem is a PR item from the GitHub search API.
type ghSearchItem 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"`
User struct {
Login string `json:"login"`
} `json:"user"`
PullRequest struct {
URL string `json:"url"`
MergedAt string `json:"merged_at"`
} `json:"pull_request"`
RepositoryURL string `json:"repository_url"`
}
func (c *GHClient) parseSearchResults(data string) ([]*PR, error) {
var items []ghSearchItem
if err := json.Unmarshal([]byte(data), &items); err != nil {
return nil, fmt.Errorf("parse search results: %w", err)
}
prs := make([]*PR, len(items))
for i, item := range 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 convertGHPR(raw *ghPR, owner, repo string) *PR {
state := strings.ToLower(raw.State)
if raw.MergedAt != "" {
state = prStateMerged
}
draft, changedFiles := false, 0
if raw.IsDraft != nil {
draft = *raw.IsDraft
}
if raw.ChangedFiles != nil {
changedFiles = *raw.ChangedFiles
}
pr := &PR{
Number: raw.Number,
Title: raw.Title,
URL: raw.URL,
HTMLURL: raw.URL,
State: state,
Body: raw.Body,
HeadBranch: raw.HeadRefName,
HeadSHA: raw.HeadRefOid,
BaseBranch: raw.BaseRefName,
AuthorLogin: raw.Author.Login,
RepoOwner: owner,
RepoName: repo,
MaintainerCanModify: raw.MaintainerCanModify,
Draft: draft,
IsDraftObserved: raw.IsDraft != nil,
Mergeable: raw.Mergeable == ghMergeableState,
MergeableState: strings.ToLower(raw.MergeStateStatus),
Additions: raw.Additions,
Deletions: raw.Deletions,
ChangedFiles: changedFiles,
ChangedFilesObserved: raw.ChangedFiles != nil,
MergedByLogin: raw.MergedBy.Login,
AutoMergeEnabled: raw.AutoMergeRequest != nil,
RequestedReviewers: convertGHRequestedReviewers(raw.ReviewRequests),
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
MergedAt: parseTimePtr(raw.MergedAt),
ClosedAt: parseTimePtr(raw.ClosedAt),
}
pr.HeadRepoNodeID = raw.HeadRepository.ID
pr.HeadRepoOwner = raw.HeadRepositoryOwner.Login
pr.HeadRepoName = raw.HeadRepository.Name
if parts := strings.SplitN(raw.HeadRepository.NameWithOwner, "/", 2); len(parts) == 2 {
if pr.HeadRepoOwner == "" {
pr.HeadRepoOwner = parts[0]
}
if pr.HeadRepoName == "" {
pr.HeadRepoName = parts[1]
}
}
pr.HeadRepoCloneURL = raw.HeadRepository.CloneURL
if pr.HeadRepoCloneURL == "" {
pr.HeadRepoCloneURL = raw.HeadRepository.HTTPSURL
}
// gh pr view does not expose baseRepository. The --repo arguments are the
// trusted target identity. Do not treat the PR base ref as the repository's
// default branch; they can differ for a PR that targets a release branch.
pr.BaseRepoOwner = owner
pr.BaseRepoName = repo
return pr
}
func convertGHIssue(raw *ghIssue, 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
}
return &Issue{
Number: raw.Number,
Title: raw.Title,
URL: raw.URL,
HTMLURL: raw.URL,
State: strings.ToLower(raw.State),
Body: raw.Body,
AuthorLogin: raw.Author.Login,
RepoOwner: owner,
RepoName: repo,
Labels: labels,
Assignees: assignees,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
ClosedAt: parseTimePtr(raw.ClosedAt),
}
}
func convertGHRequestedReviewers(raw []ghRequestedReviewer) []RequestedReviewer {
reviewers := make([]RequestedReviewer, 0, len(raw))
for _, req := range raw {
switch req.TypeName {
case "Team":
login := req.Slug
if login == "" {
login = req.Name
}
if login == "" {
continue
}
reviewers = append(reviewers, RequestedReviewer{Login: login, Type: reviewerTypeTeam})
default:
if req.Login == "" {
continue
}
reviewers = append(reviewers, RequestedReviewer{Login: req.Login, Type: reviewerTypeUser})
}
}
return reviewers
}
func parseTimePtr(s string) *time.Time {
if s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil
}
return &t
}
// parseRepoURL extracts owner and repo from a GitHub API URL like
// "https://api.github.com/repos/owner/repo".
func parseRepoURL(url string) (string, string) {
parts := strings.Split(url, "/")
if len(parts) < 2 {
return "", ""
}
return parts[len(parts)-2], parts[len(parts)-1]
}
func appendSinceQuery(endpoint string, since *time.Time) string {
if since == nil {
return endpoint
}
return endpoint + "?since=" + url.QueryEscape(since.Format(time.RFC3339))
}