Files
kandev/apps/backend/internal/github/graphql_test.go
T
José Almeida 36b5c682a9 fix(github): detect fork PRs by branch (#1182)
* fix(github): detect fork PRs by branch

* fix(github): align fork branch detection paths

* fix(github): skip duplicate branch PR matches

* test(github): remove stale branch owner fixtures

* test(github): remove stale PAT branch owner fixture

* fix(github): surface branch decode errors
2026-05-31 11:59:15 +01:00

416 lines
14 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
// stubGraphQLExecutor lets tests inspect/return canned responses without
// touching HTTP or the gh CLI.
type stubGraphQLExecutor struct {
queries []string
response string
err error
}
func (s *stubGraphQLExecutor) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error {
s.queries = append(s.queries, query)
if s.err != nil {
return s.err
}
return json.Unmarshal([]byte(s.response), out)
}
func TestBuildBatchedPRQuery_GroupsByRepo(t *testing.T) {
q, _ := buildBatchedPRQuery([]graphQLPRRef{
{Owner: "octo", Repo: "alpha", Number: 1},
{Owner: "octo", Repo: "alpha", Number: 2},
{Owner: "octo", Repo: "beta", Number: 9},
})
if !strings.Contains(q, `repo0: repository(owner: "octo", name: "alpha")`) {
t.Errorf("expected repo0 alias for octo/alpha, got: %s", q)
}
if !strings.Contains(q, `repo1: repository(owner: "octo", name: "beta")`) {
t.Errorf("expected repo1 alias for octo/beta, got: %s", q)
}
if !strings.Contains(q, `pr0: pullRequest(number: 1)`) ||
!strings.Contains(q, `pr1: pullRequest(number: 2)`) {
t.Errorf("expected aliased pullRequests inside repo0: %s", q)
}
if !strings.Contains(q, "rateLimit") {
t.Errorf("expected rateLimit field in query")
}
}
func TestBuildBatchedBranchQuery_AliasesAllBranches(t *testing.T) {
q, _ := buildBatchedBranchQuery([]graphQLBranchRef{
{Owner: "o", Repo: "r", Branch: "feat-1"},
{Owner: "o", Repo: "r", Branch: "feat-2"},
})
if !strings.Contains(q, `b0: repository`) || !strings.Contains(q, `b1: repository`) {
t.Errorf("expected b0/b1 aliases: %s", q)
}
if !strings.Contains(q, `pullRequests(first: 2, states: OPEN, headRefName: "feat-1")`) {
t.Errorf("expected headRefName lookup for feat-1: %s", q)
}
if strings.Contains(q, `ref(qualifiedName:`) {
t.Errorf("branch lookup should not require a base-repo ref: %s", q)
}
}
func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{
"data": {
"repo0": {
"pr0": {
"state": "OPEN", "title": "PR A", "url": "https://x/1",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "h1", "baseRefName": "main", "headRefOid": "abc",
"author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z",
"reviews": {"nodes": [{"state": "APPROVED"}]},
"reviewRequests": {"totalCount": 0},
"commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}
}
},
"rateLimit": {"limit":5000, "remaining":4999, "resetAt":"2030-01-01T00:00:00Z", "cost":1}
}
}`,
}
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
{Owner: "o", Repo: "r", Number: 42},
})
if err != nil {
t.Fatalf("run: %v", err)
}
status, ok := got[prStatusCacheKey("o", "r", 42)]
if !ok {
t.Fatalf("expected status for o/r#42, got keys: %v", keysOf(got))
}
if status.ReviewState != "approved" {
t.Errorf("ReviewState = %q, want approved", status.ReviewState)
}
if status.ChecksState != "success" {
t.Errorf("ChecksState = %q, want success", status.ChecksState)
}
if status.PR == nil || status.PR.Title != "PR A" {
t.Errorf("PR.Title mismatch: %#v", status.PR)
}
}
func TestRunBatchedPRQuery_DecodesMultipleReposIntoCorrectKeys(t *testing.T) {
// Two repos in one batch: octo/alpha#1 and octo/beta#9. The decoder sorts
// repo group keys; this test guards against future drift between
// buildBatchedPRQuery's sort and decodeBatchedPRChunk's sort by checking
// each PR lands in its correct key slot.
exec := &stubGraphQLExecutor{
response: `{
"data": {
"repo0": {
"pr0": {
"state": "OPEN", "title": "alpha PR", "url": "https://x/a/1",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "h1", "baseRefName": "main", "headRefOid": "aaa",
"author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}
},
"repo1": {
"pr0": {
"state": "OPEN", "title": "beta PR", "url": "https://x/b/9",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "h2", "baseRefName": "main", "headRefOid": "bbb",
"author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}
}
}
}`,
}
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
{Owner: "octo", Repo: "alpha", Number: 1},
{Owner: "octo", Repo: "beta", Number: 9},
})
if err != nil {
t.Fatalf("run: %v", err)
}
if s, ok := got[prStatusCacheKey("octo", "alpha", 1)]; !ok || s.PR == nil || s.PR.Title != "alpha PR" {
t.Errorf("alpha#1 not decoded into correct slot: %#v", s)
}
if s, ok := got[prStatusCacheKey("octo", "beta", 9)]; !ok || s.PR == nil || s.PR.Title != "beta PR" {
t.Errorf("beta#9 not decoded into correct slot: %#v", s)
}
}
func TestRunBatchedPRQuery_PropagatesError(t *testing.T) {
exec := &stubGraphQLExecutor{err: errors.New("graphql 500")}
_, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}})
if err == nil {
t.Fatalf("expected error to propagate")
}
}
func TestRunBatchedPRQuery_SurfacesGraphQLErrors(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{"data": {}, "errors": [{"message": "Field 'foo' doesn't exist on type 'Repository'"}]}`,
}
_, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}})
if err == nil {
t.Fatalf("expected error from non-empty errors array")
}
if !strings.Contains(err.Error(), "Field 'foo'") {
t.Errorf("expected error to include first message, got: %v", err)
}
}
func TestRunBatchedBranchQuery_SurfacesGraphQLErrors(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{"data": {}, "errors": [{"message": "rate limited"}, {"message": "secondary"}]}`,
}
_, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{{Owner: "o", Repo: "r", Branch: "main"}})
if err == nil {
t.Fatalf("expected error from non-empty errors array")
}
if !strings.Contains(err.Error(), "rate limited") || !strings.Contains(err.Error(), "and 1 more") {
t.Errorf("expected error to include first message + count, got: %v", err)
}
}
func TestRunBatchedBranchQuery_SurfacesDecodeErrors(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{"data": {"b0": {"pullRequests": {"nodes": [{"number": "bad"}]}}}}`,
}
_, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{{Owner: "o", Repo: "r", Branch: "feat"}})
if err == nil {
t.Fatalf("expected malformed branch node to return an error")
}
if !strings.Contains(err.Error(), "decode branch alias b0") {
t.Errorf("expected error to identify branch alias, got: %v", err)
}
}
func TestRunBatchedBranchQuery_DecodesPRNode(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{
"data": {
"b0": {
"pullRequests": {
"nodes": [{
"number": 7,
"state": "OPEN", "title": "branch PR", "url": "https://x/7",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef",
"author": {"login":"alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}]
}
}
}
}`,
}
got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{
{Owner: "o", Repo: "r", Branch: "feat"},
})
if err != nil {
t.Fatalf("run: %v", err)
}
status, ok := got[graphqlBranchKey("o", "r", "feat")]
if !ok {
t.Fatalf("expected branch result, got keys: %v", keysOf(got))
}
if status.PR == nil || status.PR.Number != 7 {
t.Errorf("expected PR number 7, got %#v", status.PR)
}
}
func TestRunBatchedBranchQuery_EmptyNodesReturnsNoResult(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{
"data": {
"b0": {
"pullRequests": {
"nodes": []
}
}
}
}`,
}
got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{
{Owner: "o", Repo: "r", Branch: "feat"},
})
if err != nil {
t.Fatalf("run: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected no branch result, got %#v", got)
}
}
func TestRunBatchedBranchQuery_SkipsAmbiguousForkHeads(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{
"data": {
"b0": {
"pullRequests": {
"nodes": [{
"number": 7,
"state": "OPEN", "title": "branch PR A", "url": "https://x/7",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef",
"author": {"login":"alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}, {
"number": 8,
"state": "OPEN", "title": "branch PR B", "url": "https://x/8",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "cafebabe",
"author": {"login":"bob"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}]
}
}
}
}`,
}
got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{
{Owner: "o", Repo: "r", Branch: "feat"},
})
if err != nil {
t.Fatalf("run: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected ambiguous fork heads to be skipped, got %#v", got)
}
}
func TestRunBatchedBranchQuery_SkipsMultipleBranchMatchesEvenWithOwnerMatch(t *testing.T) {
exec := &stubGraphQLExecutor{
response: `{
"data": {
"b0": {
"pullRequests": {
"nodes": [{
"number": 7,
"state": "OPEN", "title": "fork PR", "url": "https://x/7",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef",
"author": {"login":"alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}, {
"number": 9,
"state": "OPEN", "title": "base PR", "url": "https://x/9",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "cafebabe",
"author": {"login":"o"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}]
}
}
}
}`,
}
got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{
{Owner: "o", Repo: "r", Branch: "feat"},
})
if err != nil {
t.Fatalf("run: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected duplicate branch matches to be skipped, got %#v", got)
}
}
func TestSummarizeReviewState_PrefersChangesRequested(t *testing.T) {
mk := func(login, state string, sec int) reviewNode {
var n reviewNode
n.Author.Login = login
n.State = state
n.SubmittedAt = time.Unix(int64(sec), 0).UTC()
return n
}
// Different reviewers: CHANGES_REQUESTED beats APPROVED.
if got := summarizeReviewState([]reviewNode{
mk("alice", "APPROVED", 1),
mk("bob", "CHANGES_REQUESTED", 2),
}); got != "changes_requested" {
t.Errorf("got %q", got)
}
// Different reviewers: APPROVED + COMMENTED -> approved.
if got := summarizeReviewState([]reviewNode{
mk("alice", "APPROVED", 1),
mk("bob", "COMMENTED", 2),
}); got != "approved" {
t.Errorf("got %q", got)
}
// Single COMMENTED -> empty.
if got := summarizeReviewState([]reviewNode{
mk("alice", "COMMENTED", 1),
}); got != "" {
t.Errorf("got %q", got)
}
// Same reviewer: CHANGES_REQUESTED then APPROVED -> approved (Greptile P1).
if got := summarizeReviewState([]reviewNode{
mk("alice", "CHANGES_REQUESTED", 1),
mk("alice", "APPROVED", 2),
}); got != "approved" {
t.Errorf("dedup: got %q, want approved", got)
}
// Same reviewer: APPROVED then CHANGES_REQUESTED -> changes_requested.
if got := summarizeReviewState([]reviewNode{
mk("alice", "APPROVED", 1),
mk("alice", "CHANGES_REQUESTED", 2),
}); got != "changes_requested" {
t.Errorf("dedup reverse: got %q, want changes_requested", got)
}
}
func TestChunkedRefs_RespectsBatchSize(t *testing.T) {
refs := make([]graphQLPRRef, graphQLBatchChunkSize+5)
chunks := chunkedRefs(refs)
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks for %d refs, got %d", len(refs), len(chunks))
}
if len(chunks[0]) != graphQLBatchChunkSize {
t.Errorf("first chunk size = %d, want %d", len(chunks[0]), graphQLBatchChunkSize)
}
if len(chunks[1]) != 5 {
t.Errorf("second chunk size = %d, want 5", len(chunks[1]))
}
}
func keysOf[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestGraphQLExecutorFor_NoopReturnsError(t *testing.T) {
if _, err := graphQLExecutorFor(&NoopClient{}); err == nil {
t.Fatalf("expected unsupported error for NoopClient")
}
if _, err := graphQLExecutorFor(NewPATClient("token")); err != nil {
t.Fatalf("PATClient should satisfy GraphQLExecutor: %v", err)
}
if _, err := graphQLExecutorFor(NewGHClient()); err != nil {
t.Fatalf("GHClient should satisfy GraphQLExecutor: %v", err)
}
}