717 lines
25 KiB
Go
717 lines
25 KiB
Go
package github
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// GraphQLExecutor runs a single GraphQL query against the GitHub API. Both
|
||
// PATClient (direct HTTP) and GHClient (`gh api graphql`) implement it so the
|
||
// batched poller works regardless of auth method.
|
||
type GraphQLExecutor interface {
|
||
ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error
|
||
}
|
||
|
||
// graphQLPRBatchAlias is the per-PR alias prefix; the index makes aliases
|
||
// unique within a single repository block.
|
||
const graphQLPRBatchAlias = "pr"
|
||
|
||
// graphQLBatchChunkSize bounds the number of aliased fields per request to
|
||
// stay well under GitHub's per-query node-count limit (500). 50 PRs × ~10
|
||
// fields = ~500 nodes, leaving headroom.
|
||
const graphQLBatchChunkSize = 50
|
||
|
||
// graphQLBranchProbeLimit fetches two matches so branch lookup can detect
|
||
// ambiguous fork heads instead of linking the first arbitrary PR.
|
||
const graphQLBranchProbeLimit = 2
|
||
|
||
// reviewNode is one PR review entry from the batched GraphQL query.
|
||
type reviewNode struct {
|
||
State string `json:"state"`
|
||
Author struct {
|
||
Login string `json:"login"`
|
||
} `json:"author"`
|
||
SubmittedAt time.Time `json:"submittedAt"`
|
||
}
|
||
|
||
// graphQLPRRef is one entry in a batched PR-status request.
|
||
type graphQLPRRef struct {
|
||
Owner string
|
||
Repo string
|
||
Number int
|
||
}
|
||
|
||
// graphQLBranchRef is one entry in a batched branch-lookup request.
|
||
type graphQLBranchRef struct {
|
||
Owner string
|
||
Repo string
|
||
Branch string
|
||
}
|
||
|
||
// chunkedRefs splits refs into chunks of at most graphQLBatchChunkSize so
|
||
// callers can keep individual GraphQL queries under the node-count limit.
|
||
func chunkedRefs[T any](refs []T) [][]T {
|
||
if len(refs) == 0 {
|
||
return nil
|
||
}
|
||
out := make([][]T, 0, (len(refs)+graphQLBatchChunkSize-1)/graphQLBatchChunkSize)
|
||
for i := 0; i < len(refs); i += graphQLBatchChunkSize {
|
||
end := i + graphQLBatchChunkSize
|
||
if end > len(refs) {
|
||
end = len(refs)
|
||
}
|
||
out = append(out, refs[i:end])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// batchedPRResult is the decoded shape of one aliased pullRequest block.
|
||
type batchedPRResult struct {
|
||
State string `json:"state"`
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
IsDraft bool `json:"isDraft"`
|
||
Mergeable string `json:"mergeable"`
|
||
MergeStatus string `json:"mergeStateStatus"`
|
||
HeadRefName string `json:"headRefName"`
|
||
BaseRefName string `json:"baseRefName"`
|
||
HeadRefOid string `json:"headRefOid"`
|
||
Additions int `json:"additions"`
|
||
Deletions int `json:"deletions"`
|
||
Author struct {
|
||
Login string `json:"login"`
|
||
} `json:"author"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
MergedAt string `json:"mergedAt"`
|
||
ClosedAt string `json:"closedAt"`
|
||
Reviews struct {
|
||
Nodes []reviewNode `json:"nodes"`
|
||
} `json:"reviews"`
|
||
ReviewRequests struct {
|
||
TotalCount int `json:"totalCount"`
|
||
} `json:"reviewRequests"`
|
||
ReviewThreads struct {
|
||
TotalCount int `json:"totalCount"`
|
||
Nodes []struct {
|
||
IsResolved bool `json:"isResolved"`
|
||
} `json:"nodes"`
|
||
} `json:"reviewThreads"`
|
||
Commits struct {
|
||
Nodes []struct {
|
||
Commit struct {
|
||
StatusCheckRollup *struct {
|
||
State string `json:"state"`
|
||
} `json:"statusCheckRollup"`
|
||
} `json:"commit"`
|
||
} `json:"nodes"`
|
||
} `json:"commits"`
|
||
}
|
||
|
||
type batchedBranchPRNode struct {
|
||
Number int `json:"number"`
|
||
batchedPRResult
|
||
}
|
||
|
||
// graphQLError mirrors a single entry in a GraphQL response's "errors" array.
|
||
type graphQLError struct {
|
||
Message string `json:"message"`
|
||
Type string `json:"type"`
|
||
Path []string `json:"path"`
|
||
}
|
||
|
||
// repoRef is a minimal (owner, repo) pair used by the batched GraphQL
|
||
// helpers to surface which repositories the response flagged as
|
||
// unresolvable. Service.SyncWatchesBatched feeds these into the negative
|
||
// cache so subsequent polls short-circuit the dead-repo storm.
|
||
type repoRef struct {
|
||
Owner string
|
||
Repo string
|
||
}
|
||
|
||
// batchedMissingReposErr is the typed error runBatchedPRQuery /
|
||
// runBatchedBranchQuery returns when the GraphQL response carried one or
|
||
// more "Could not resolve to a Repository" entries. Callers use
|
||
// errors.As to extract Repos and populate Service.repoErrorCache. When
|
||
// Inner is nil, the response contained ONLY resolution failures and
|
||
// the partial-decode result returned alongside the error is still safe
|
||
// to consume for the repos that did resolve; when Inner is non-nil,
|
||
// other (non-resolution) errors were also present and the caller should
|
||
// treat the whole batch as failed for those.
|
||
type batchedMissingReposErr struct {
|
||
Repos []repoRef
|
||
Inner error
|
||
}
|
||
|
||
func (e *batchedMissingReposErr) Error() string {
|
||
if e.Inner != nil {
|
||
return e.Inner.Error()
|
||
}
|
||
parts := make([]string, len(e.Repos))
|
||
for i, r := range e.Repos {
|
||
parts[i] = r.Owner + "/" + r.Repo
|
||
}
|
||
return "github: repositories not resolvable: " + strings.Join(parts, ", ")
|
||
}
|
||
|
||
// Is lets callers errors.Is(err, ErrRepoNotResolvable) without having to
|
||
// type-assert. The sentinel is the canonical "stop hammering this repo"
|
||
// signal across the package.
|
||
func (e *batchedMissingReposErr) Is(target error) bool {
|
||
return target == ErrRepoNotResolvable
|
||
}
|
||
|
||
// Unwrap exposes the composite non-resolution error (if any) so callers
|
||
// can errors.As / errors.Is past the batched-missing wrapper.
|
||
func (e *batchedMissingReposErr) Unwrap() error { return e.Inner }
|
||
|
||
// graphQLErrorsToErr returns a non-nil error when the GraphQL response carries
|
||
// a top-level "errors" array. The first message is included so logs are
|
||
// actionable; the count is appended when there are multiple.
|
||
func graphQLErrorsToErr(errs []graphQLError) error {
|
||
if len(errs) == 0 {
|
||
return nil
|
||
}
|
||
if len(errs) == 1 {
|
||
return fmt.Errorf("graphql error: %s", errs[0].Message)
|
||
}
|
||
return fmt.Errorf("graphql error: %s (and %d more)", errs[0].Message, len(errs)-1)
|
||
}
|
||
|
||
// classifyBatchedErrors splits the GraphQL errors[] array into "this
|
||
// repo is permanently unresolvable" and "everything else", mapping each
|
||
// resolution error's path-prefix alias back to the (owner, repo) it
|
||
// referred to via aliasToRepo. The caller (runBatchedPRQuery /
|
||
// runBatchedBranchQuery) lifts the resolution misses into the typed
|
||
// batchedMissingReposErr; the residual non-resolution errors are
|
||
// collapsed via graphQLErrorsToErr exactly as before so the storm-fix
|
||
// doesn't change behavior for unrelated GraphQL failures.
|
||
func classifyBatchedErrors(errs []graphQLError, aliasToRepo map[string]repoRef) (missing []repoRef, residual []graphQLError) {
|
||
seen := map[string]bool{}
|
||
for _, e := range errs {
|
||
if isRepoNotResolvableErr(errors.New(e.Message)) && len(e.Path) > 0 {
|
||
ref, ok := aliasToRepo[e.Path[0]]
|
||
if ok {
|
||
key := ref.Owner + "/" + ref.Repo
|
||
if !seen[key] {
|
||
seen[key] = true
|
||
missing = append(missing, ref)
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
residual = append(residual, e)
|
||
}
|
||
return missing, residual
|
||
}
|
||
|
||
// aliasMapForPRRefs returns alias -> repoRef for the
|
||
// owner/repo-sorted ordering buildBatchedPRQuery applies. A
|
||
// resolution-failure error's path[0] is always the outer repository
|
||
// alias ("repo0", "repo1", ...).
|
||
func aliasMapForPRRefs(refs []graphQLPRRef) map[string]repoRef {
|
||
byKey := map[string]repoRef{}
|
||
var keys []string
|
||
for _, r := range refs {
|
||
k := r.Owner + "/" + r.Repo
|
||
if _, ok := byKey[k]; !ok {
|
||
byKey[k] = repoRef{Owner: r.Owner, Repo: r.Repo}
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
sort.Strings(keys)
|
||
out := make(map[string]repoRef, len(keys))
|
||
for i, k := range keys {
|
||
out[fmt.Sprintf("repo%d", i)] = byKey[k]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// aliasMapForBranchRefs returns alias -> repoRef for the input-order
|
||
// b0, b1, ... aliases buildBatchedBranchQuery applies.
|
||
func aliasMapForBranchRefs(refs []graphQLBranchRef) map[string]repoRef {
|
||
out := make(map[string]repoRef, len(refs))
|
||
for i, r := range refs {
|
||
out[fmt.Sprintf("b%d", i)] = repoRef{Owner: r.Owner, Repo: r.Repo}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// wrapBatchedErrors composes a classifyBatchedErrors result into the
|
||
// (result, error) shape runBatchedPRQuery / runBatchedBranchQuery
|
||
// already return. Behaviour:
|
||
// - no resolution misses, no residual errors → (nil): caller continues
|
||
// - no resolution misses, residual errors only → return composite err
|
||
// unchanged (pre-storm-fix semantics)
|
||
// - resolution misses present → return *batchedMissingReposErr; Inner
|
||
// is nil iff there were no residuals so callers can keep the
|
||
// partial decode, otherwise Inner carries the composite
|
||
func wrapBatchedErrors(missing []repoRef, residual []graphQLError) error {
|
||
residualErr := graphQLErrorsToErr(residual)
|
||
if len(missing) == 0 {
|
||
return residualErr
|
||
}
|
||
return &batchedMissingReposErr{Repos: missing, Inner: residualErr}
|
||
}
|
||
|
||
// graphQLRateLimit mirrors GitHub's top-level rateLimit field, the most
|
||
// accurate source of GraphQL quota since limits are point-cost based.
|
||
type graphQLRateLimit struct {
|
||
Limit int `json:"limit"`
|
||
Remaining int `json:"remaining"`
|
||
ResetAt time.Time `json:"resetAt"`
|
||
Cost int `json:"cost"`
|
||
}
|
||
|
||
// buildBatchedPRQuery groups refs by (owner, repo) and emits one aliased
|
||
// repository block per group, with one aliased pullRequest per number inside.
|
||
// The shape mirrors gh pr view fields used by the existing converter so we
|
||
// can reuse the conversion logic without reshaping callers.
|
||
func buildBatchedPRQuery(refs []graphQLPRRef) (string, map[string]any) {
|
||
// Group refs by (owner, repo) and assign deterministic indices so aliases
|
||
// stay stable across runs (helpful for tests and snapshot debugging).
|
||
type group struct {
|
||
owner string
|
||
repo string
|
||
numbers []int
|
||
}
|
||
byKey := map[string]*group{}
|
||
keys := []string{}
|
||
for _, r := range refs {
|
||
key := r.Owner + "/" + r.Repo
|
||
g, ok := byKey[key]
|
||
if !ok {
|
||
g = &group{owner: r.Owner, repo: r.Repo}
|
||
byKey[key] = g
|
||
keys = append(keys, key)
|
||
}
|
||
g.numbers = append(g.numbers, r.Number)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
var b strings.Builder
|
||
b.WriteString("query Batch { ")
|
||
for repoIdx, key := range keys {
|
||
g := byKey[key]
|
||
fmt.Fprintf(&b, `repo%d: repository(owner: %q, name: %q) { `, repoIdx, g.owner, g.repo)
|
||
for prIdx, n := range g.numbers {
|
||
fmt.Fprintf(&b, `%s%d: pullRequest(number: %d) { %s } `, graphQLPRBatchAlias, prIdx, n, prFieldsBlock())
|
||
}
|
||
b.WriteString(`} `)
|
||
}
|
||
b.WriteString(`rateLimit { limit remaining resetAt cost } `)
|
||
b.WriteString(`}`)
|
||
return b.String(), nil
|
||
}
|
||
|
||
// prFieldsBlock is the GraphQL field selection used in every batched PR
|
||
// query. Kept as a constant to make snapshot assertions stable and to keep
|
||
// the batched and single-PR paths returning the same data.
|
||
func prFieldsBlock() string {
|
||
return `state title url isDraft mergeable mergeStateStatus ` +
|
||
`headRefName baseRefName headRefOid additions deletions ` +
|
||
`author { login } createdAt updatedAt mergedAt closedAt ` +
|
||
`reviews(last: 100) { nodes { state author { login } submittedAt } } ` +
|
||
`reviewRequests(first: 0) { totalCount } ` +
|
||
`reviewThreads(first: 100) { totalCount nodes { isResolved } } ` +
|
||
`commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`
|
||
}
|
||
|
||
// buildBatchedBranchQuery emits one aliased pullRequests(headRefName:) block
|
||
// per (owner, repo, branch). Used to batch the "find PR by branch" path.
|
||
func buildBatchedBranchQuery(refs []graphQLBranchRef) (string, map[string]any) {
|
||
var b strings.Builder
|
||
b.WriteString("query Branches { ")
|
||
for i, r := range refs {
|
||
fmt.Fprintf(&b, `b%d: repository(owner: %q, name: %q) { pullRequests(first: %d, states: OPEN, headRefName: %q) { nodes { number %s } } } `,
|
||
i, r.Owner, r.Repo, graphQLBranchProbeLimit, r.Branch, prFieldsBlock())
|
||
}
|
||
b.WriteString(`rateLimit { limit remaining resetAt cost } `)
|
||
b.WriteString(`}`)
|
||
return b.String(), nil
|
||
}
|
||
|
||
// convertBatchedPRResult turns a batched GraphQL row into the (PR, PRStatus)
|
||
// pair the existing poller code uses.
|
||
func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int) *PRStatus {
|
||
state := strings.ToLower(raw.State)
|
||
if raw.MergedAt != "" {
|
||
state = prStateMerged
|
||
}
|
||
pr := &PR{
|
||
Number: number,
|
||
Title: raw.Title,
|
||
URL: raw.URL,
|
||
HTMLURL: raw.URL,
|
||
State: state,
|
||
HeadBranch: raw.HeadRefName,
|
||
HeadSHA: raw.HeadRefOid,
|
||
BaseBranch: raw.BaseRefName,
|
||
AuthorLogin: raw.Author.Login,
|
||
RepoOwner: owner,
|
||
RepoName: repo,
|
||
Draft: raw.IsDraft,
|
||
Mergeable: raw.Mergeable == "MERGEABLE",
|
||
MergeableState: strings.ToLower(raw.MergeStatus),
|
||
Additions: raw.Additions,
|
||
Deletions: raw.Deletions,
|
||
CreatedAt: raw.CreatedAt,
|
||
UpdatedAt: raw.UpdatedAt,
|
||
MergedAt: parseTimePtr(raw.MergedAt),
|
||
ClosedAt: parseTimePtr(raw.ClosedAt),
|
||
}
|
||
|
||
reviewState := summarizeReviewState(raw.Reviews.Nodes)
|
||
checksState := ""
|
||
if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
||
checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State)
|
||
}
|
||
// Count unresolved threads from the fetched nodes. When the page is
|
||
// capped (totalCount > nodes), fall back to totalCount for the
|
||
// resolved-vs-unresolved estimate so the popover doesn't undercount on
|
||
// busy PRs. The fallback is conservative: we have no way to tell from
|
||
// the truncated page how many of the un-fetched threads are resolved,
|
||
// so attribute the unseen tail to "unresolved" — the popover's value
|
||
// is meant to be actionable, not exact, and over-reporting is safer
|
||
// than silently hiding open feedback.
|
||
unresolved := 0
|
||
for _, t := range raw.ReviewThreads.Nodes {
|
||
if !t.IsResolved {
|
||
unresolved++
|
||
}
|
||
}
|
||
if total := raw.ReviewThreads.TotalCount; total > len(raw.ReviewThreads.Nodes) {
|
||
unresolved += total - len(raw.ReviewThreads.Nodes)
|
||
}
|
||
return &PRStatus{
|
||
PR: pr,
|
||
ReviewState: reviewState,
|
||
ChecksState: checksState,
|
||
MergeableState: pr.MergeableState,
|
||
ReviewCount: countApprovedReviewerNodes(raw.Reviews.Nodes),
|
||
PendingReviewCount: raw.ReviewRequests.TotalCount,
|
||
ReviewCountsPopulated: true,
|
||
UnresolvedReviewThreads: unresolved,
|
||
UnresolvedReviewThreadsPopulated: true,
|
||
}
|
||
}
|
||
|
||
// reviewNodesToSamples converts the GraphQL reviewNode shape to the shared
|
||
// reviewSample slice used by the dedup helpers in client_helpers.go.
|
||
func reviewNodesToSamples(nodes []reviewNode) []reviewSample {
|
||
samples := make([]reviewSample, len(nodes))
|
||
for i, n := range nodes {
|
||
samples[i] = reviewSample{author: n.Author.Login, state: n.State, at: n.SubmittedAt}
|
||
}
|
||
return samples
|
||
}
|
||
|
||
// countApprovedReviewerNodes returns the number of distinct authors whose
|
||
// latest review state is APPROVED, on the GraphQL reviewNode shape. Thin
|
||
// adapter over countApprovedAuthors so REST and GraphQL agree on what
|
||
// "Approved (N)" means.
|
||
func countApprovedReviewerNodes(nodes []reviewNode) int {
|
||
return countApprovedAuthors(reviewNodesToSamples(nodes))
|
||
}
|
||
|
||
// summarizeReviewState collapses the review history to a single
|
||
// "approved"/"changes_requested"/"" value. Per-reviewer dedup: each
|
||
// reviewer's most-recent binding review wins, so a CHANGES_REQUESTED
|
||
// followed by APPROVED from the same author resolves to APPROVED.
|
||
// CHANGES_REQUESTED beats APPROVED across distinct reviewers.
|
||
func summarizeReviewState(nodes []reviewNode) string {
|
||
return reduceReviewSummary(reviewNodesToSamples(nodes))
|
||
}
|
||
|
||
// PATClient.ExecuteGraphQL satisfies GraphQLExecutor by POSTing to /graphql.
|
||
func (c *PATClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error {
|
||
body, err := json.Marshal(map[string]any{"query": query, "variables": variables})
|
||
if err != nil {
|
||
return fmt.Errorf("marshal graphql: %w", err)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubAPIBase+"/graphql", 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("graphql request: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
c.recordRateHeaders(resp, "/graphql")
|
||
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
||
if resp.StatusCode >= 400 {
|
||
c.maybeMarkRateExhaustedFromBody("/graphql", resp.StatusCode, respBody)
|
||
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: "/graphql", Body: string(respBody)}
|
||
}
|
||
if err := json.Unmarshal(respBody, out); err != nil {
|
||
return fmt.Errorf("decode graphql response: %w", err)
|
||
}
|
||
recordGraphQLRateFromPayload(c.rateTracker, respBody)
|
||
return nil
|
||
}
|
||
|
||
// recordGraphQLRateFromPayload extracts data.rateLimit from a GraphQL
|
||
// response body and feeds it to the tracker. The GraphQL rate-limit field is
|
||
// point-cost based and more accurate than HTTP headers.
|
||
func recordGraphQLRateFromPayload(tracker *RateTracker, body []byte) {
|
||
if tracker == nil {
|
||
return
|
||
}
|
||
var probe struct {
|
||
Data map[string]json.RawMessage `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &probe); err != nil {
|
||
return
|
||
}
|
||
raw, ok := probe.Data["rateLimit"]
|
||
if !ok || len(raw) == 0 {
|
||
return
|
||
}
|
||
var rl graphQLRateLimit
|
||
if err := json.Unmarshal(raw, &rl); err != nil {
|
||
return
|
||
}
|
||
tracker.Record(RateSnapshot{
|
||
Resource: ResourceGraphQL,
|
||
Limit: rl.Limit,
|
||
Remaining: rl.Remaining,
|
||
ResetAt: rl.ResetAt,
|
||
UpdatedAt: time.Now().UTC(),
|
||
})
|
||
}
|
||
|
||
// GHClient.ExecuteGraphQL satisfies GraphQLExecutor via `gh api graphql -f query=...`.
|
||
// Variables are passed as -F field_name=value entries; they are ignored when
|
||
// the query has no $variables.
|
||
func (c *GHClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error {
|
||
args := []string{"api", "graphql", "-f", "query=" + query}
|
||
for k, v := range variables {
|
||
args = append(args, "-F", fmt.Sprintf("%s=%v", k, v))
|
||
}
|
||
stdout, err := c.run(ctx, args...)
|
||
if err != nil {
|
||
return fmt.Errorf("gh graphql: %w", err)
|
||
}
|
||
if err := json.Unmarshal([]byte(stdout), out); err != nil {
|
||
return fmt.Errorf("decode graphql response: %w", err)
|
||
}
|
||
recordGraphQLRateFromPayload(c.rateTracker, []byte(stdout))
|
||
return nil
|
||
}
|
||
|
||
// noopGraphQLExecutorErr is returned when the active client is the noop
|
||
// fallback (no auth). Caller paths must handle this gracefully — typically
|
||
// by skipping the batched call entirely.
|
||
var errGraphQLUnsupported = fmt.Errorf("github client does not support GraphQL")
|
||
|
||
// graphQLExecutorFor returns an executor for the given client, or
|
||
// errGraphQLUnsupported when the client is nil/noop.
|
||
func graphQLExecutorFor(client Client) (GraphQLExecutor, error) {
|
||
if exec, ok := client.(GraphQLExecutor); ok && client != nil {
|
||
return exec, nil
|
||
}
|
||
return nil, errGraphQLUnsupported
|
||
}
|
||
|
||
// runBatchedPRQuery executes the batched query in chunks and merges the
|
||
// results into a single map keyed by prStatusCacheKey(owner, repo, number).
|
||
func runBatchedPRQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLPRRef) (map[string]*PRStatus, error) {
|
||
if exec == nil || len(refs) == 0 {
|
||
return nil, nil
|
||
}
|
||
result := make(map[string]*PRStatus, len(refs))
|
||
// Accumulate missing / residual errors across all chunks so a chunk
|
||
// of purely-dead repos doesn't short-circuit the loop and drop the
|
||
// good data in later chunks. Pre-fix, a workspace with >50 watches
|
||
// where the first 50 happened to be dead would silently skip the
|
||
// remaining watches on every poll cycle until the first batch's
|
||
// repos landed in the negative cache.
|
||
var allMissing []repoRef
|
||
var allResidual []graphQLError
|
||
for _, chunk := range chunkedRefs(refs) {
|
||
query, vars := buildBatchedPRQuery(chunk)
|
||
var resp struct {
|
||
Data map[string]json.RawMessage `json:"data"`
|
||
Errors []graphQLError `json:"errors"`
|
||
}
|
||
if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil {
|
||
return nil, err
|
||
}
|
||
// GitHub returns HTTP 200 with a top-level "errors" array for partial
|
||
// auth failures, schema mismatches, or per-alias errors. Split out
|
||
// "Could not resolve to a Repository" entries via classifyBatchedErrors
|
||
// so SyncWatchesBatched can negative-cache the dead repos and keep
|
||
// partial results for the ones that resolved; non-resolution errors
|
||
// still bubble up so the caller falls back to per-watch checks.
|
||
missing, residual := classifyBatchedErrors(resp.Errors, aliasMapForPRRefs(chunk))
|
||
if err := decodeBatchedPRChunk(chunk, resp.Data, result); err != nil {
|
||
return nil, err
|
||
}
|
||
allMissing = append(allMissing, missing...)
|
||
allResidual = append(allResidual, residual...)
|
||
}
|
||
if err := wrapBatchedErrors(allMissing, allResidual); err != nil {
|
||
// When the error is purely "missing repos" across all chunks,
|
||
// partial Statuses for the other refs are still in `result`;
|
||
// surface them to the caller alongside the typed error so it
|
||
// can populate the negative cache without dropping the good data.
|
||
if len(allMissing) > 0 && len(allResidual) == 0 {
|
||
return result, err
|
||
}
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// decodeBatchedPRChunk maps the aliased response back to the input refs and
|
||
// fills in the result map. Refs whose alias is missing or null are skipped
|
||
// (e.g. PR was deleted upstream); the next poller tick will retry.
|
||
func decodeBatchedPRChunk(refs []graphQLPRRef, data map[string]json.RawMessage, result map[string]*PRStatus) error {
|
||
type group struct {
|
||
idx int
|
||
owner string
|
||
repo string
|
||
prRefs []graphQLPRRef
|
||
}
|
||
groups := []*group{}
|
||
byKey := map[string]*group{}
|
||
for _, r := range refs {
|
||
k := r.Owner + "/" + r.Repo
|
||
g, ok := byKey[k]
|
||
if !ok {
|
||
g = &group{idx: len(groups), owner: r.Owner, repo: r.Repo}
|
||
byKey[k] = g
|
||
groups = append(groups, g)
|
||
}
|
||
g.prRefs = append(g.prRefs, r)
|
||
}
|
||
keys := make([]string, 0, len(byKey))
|
||
for k := range byKey {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
// Reassign group indices in sorted order to match buildBatchedPRQuery.
|
||
for i, k := range keys {
|
||
byKey[k].idx = i
|
||
}
|
||
for _, k := range keys {
|
||
g := byKey[k]
|
||
raw, ok := data[fmt.Sprintf("repo%d", g.idx)]
|
||
if !ok || len(raw) == 0 {
|
||
continue
|
||
}
|
||
repoBlock := map[string]json.RawMessage{}
|
||
if err := json.Unmarshal(raw, &repoBlock); err != nil {
|
||
return fmt.Errorf("decode repo block: %w", err)
|
||
}
|
||
for prIdx, ref := range g.prRefs {
|
||
alias := fmt.Sprintf("%s%d", graphQLPRBatchAlias, prIdx)
|
||
rawPR, ok := repoBlock[alias]
|
||
if !ok || len(rawPR) == 0 || string(rawPR) == "null" {
|
||
continue
|
||
}
|
||
var raw batchedPRResult
|
||
if err := json.Unmarshal(rawPR, &raw); err != nil {
|
||
return fmt.Errorf("decode pr alias %s: %w", alias, err)
|
||
}
|
||
result[prStatusCacheKey(ref.Owner, ref.Repo, ref.Number)] = convertBatchedPRResult(&raw, ref.Owner, ref.Repo, ref.Number)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// runBatchedBranchQuery executes the branch-lookup query in chunks and maps
|
||
// each branch name to its unambiguous OPEN PR (if any). Result keys are
|
||
// "owner/repo/branch" so callers can index by their input refs.
|
||
func runBatchedBranchQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLBranchRef) (map[string]*PRStatus, error) {
|
||
if exec == nil || len(refs) == 0 {
|
||
return nil, nil
|
||
}
|
||
result := make(map[string]*PRStatus, len(refs))
|
||
// Same accumulation pattern as runBatchedPRQuery — see that function
|
||
// for the rationale (one dead-repo chunk must not drop later chunks).
|
||
var allMissing []repoRef
|
||
var allResidual []graphQLError
|
||
for _, chunk := range chunkedRefs(refs) {
|
||
query, vars := buildBatchedBranchQuery(chunk)
|
||
var resp struct {
|
||
Data map[string]json.RawMessage `json:"data"`
|
||
Errors []graphQLError `json:"errors"`
|
||
}
|
||
if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil {
|
||
return nil, err
|
||
}
|
||
missing, residual := classifyBatchedErrors(resp.Errors, aliasMapForBranchRefs(chunk))
|
||
if err := decodeBatchedBranchChunk(chunk, resp.Data, result); err != nil {
|
||
return nil, err
|
||
}
|
||
allMissing = append(allMissing, missing...)
|
||
allResidual = append(allResidual, residual...)
|
||
}
|
||
if err := wrapBatchedErrors(allMissing, allResidual); err != nil {
|
||
if len(allMissing) > 0 && len(allResidual) == 0 {
|
||
return result, err
|
||
}
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func decodeBatchedBranchChunk(refs []graphQLBranchRef, data map[string]json.RawMessage, result map[string]*PRStatus) error {
|
||
for i, ref := range refs {
|
||
alias := fmt.Sprintf("b%d", i)
|
||
raw, ok := data[alias]
|
||
if !ok || len(raw) == 0 || string(raw) == "null" {
|
||
continue
|
||
}
|
||
var inner struct {
|
||
PullRequests struct {
|
||
Nodes []batchedBranchPRNode `json:"nodes"`
|
||
} `json:"pullRequests"`
|
||
}
|
||
if err := json.Unmarshal(raw, &inner); err != nil {
|
||
return fmt.Errorf("decode branch alias %s: %w", alias, err)
|
||
}
|
||
node, ok := selectBatchedBranchPRNode(inner.PullRequests.Nodes)
|
||
if !ok {
|
||
continue
|
||
}
|
||
status := convertBatchedPRResult(&node.batchedPRResult, ref.Owner, ref.Repo, node.Number)
|
||
result[graphqlBranchKey(ref.Owner, ref.Repo, ref.Branch)] = status
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func selectBatchedBranchPRNode(nodes []batchedBranchPRNode) (*batchedBranchPRNode, bool) {
|
||
if len(nodes) != 1 {
|
||
return nil, false
|
||
}
|
||
return &nodes[0], true
|
||
}
|
||
|
||
// graphqlBranchKey is the lookup key used in batched-branch result maps.
|
||
// Named graphql* to avoid collision with the mock_client.go branchKey type.
|
||
func graphqlBranchKey(owner, repo, branch string) string {
|
||
return owner + "/" + repo + "/" + branch
|
||
}
|
||
|
||
// Compile-time assertions that the existing CLI/PAT clients satisfy
|
||
// GraphQLExecutor. If a client stops implementing it the build fails here.
|
||
var (
|
||
_ GraphQLExecutor = (*PATClient)(nil)
|
||
_ GraphQLExecutor = (*GHClient)(nil)
|
||
)
|