Files
kandev/apps/backend/internal/github/graphql.go
T

717 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
)