package main import ( "context" "encoding/json" "net/http" "net/http/httptest" "reflect" "strings" "testing" "time" ) func TestListBranchesPaginatesAndMarksTheDefaultBranch(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } if !strings.Contains(request.Query, "query RepositoryBranches") { t.Fatalf("unexpected query: %s", request.Query) } if requests == 1 { if request.Variables["after"] != nil { t.Fatalf("first cursor = %#v", request.Variables["after"]) } _, _ = w.Write([]byte(`{"data":{"repository":{ "defaultBranchRef":{"name":"main"}, "refs":{"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[ {"name":"feature/old","target":{"committedDate":"2024-01-01T00:00:00Z"}}, {"name":"main","target":{"committedDate":"2025-01-01T00:00:00Z"}} ]} }}}`)) return } if request.Variables["after"] != "next" { t.Fatalf("second cursor = %#v", request.Variables["after"]) } _, _ = w.Write([]byte(`{"data":{"repository":{ "defaultBranchRef":{"name":"main"}, "refs":{"pageInfo":{"hasNextPage":false},"nodes":[ {"name":"release/2.0","target":{"committedDate":"2026-07-28T00:00:00Z"}} ]} }}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") branches, err := client.ListBranches(context.Background(), "o", "r") if err != nil { t.Fatal(err) } if requests != 2 || len(branches) != 3 { t.Fatalf("requests=%d branches=%#v", requests, branches) } if branches[0].Name != "main" || !branches[0].IsDefault { t.Fatalf("default branch was not first and marked: %#v", branches) } wantUpdated := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC) if branches[1].Name != "release/2.0" || !branches[1].UpdatedAt.Equal(wantUpdated) { t.Fatalf("fresh branch was not decoded and sorted: %#v", branches) } } func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer secret" { t.Fatalf("authorization = %q", got) } var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } if got := request.Variables["query"]; got != "is:pr is:open sort:updated-desc repo:o/r assignee:@me" { t.Fatalf("search query = %q", got) } _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ "viewer": map[string]any{"login": "zam"}, "search": map[string]any{"nodes": []any{ map[string]any{ "id": "1", "number": 1, "title": "assigned", "url": "u", "isDraft": false, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "other"}, "repository": map[string]any{"name": "r", "nameWithOwner": "o/r", "owner": map[string]any{"login": "o"}}, "reviewThreads": map[string]any{"totalCount": 2}, }, }}, }}) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") prs, err := client.ListPullRequests(context.Background(), "o", "r", 50, false) if err != nil { t.Fatal(err) } if len(prs) != 1 || prs[0].Number != 1 || prs[0].ReviewCount != 2 || prs[0].Owner != "o" || prs[0].Repository != "r" || prs[0].RepoWithOwner != "o/r" { t.Fatalf("unexpected PRs: %#v", prs) } } func TestListPullRequestsSearchesAllRepositoriesByDefault(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } query, _ := request.Variables["query"].(string) if !strings.Contains(query, "assignee:@me") || strings.Contains(query, "repo:") { t.Fatalf("global search query = %q", query) } _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"zam"},"search":{"nodes":[]}}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") if _, err := client.ListPullRequests(context.Background(), "", "", 50, false); err != nil { t.Fatal(err) } } func TestListPullRequestsRejectsGlobalShowAll(t *testing.T) { client := NewGitHubClient("unused", "secret") if _, err := client.ListPullRequests(context.Background(), "", "", 50, true); err == nil { t.Fatal("global --all search was accepted") } } func TestListPullRequestsPaginatesToConfiguredLimit(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } cursor := request.Variables["after"] if requests == 1 { if cursor != nil || request.Variables["first"] != float64(100) { t.Fatalf("first page variables = %#v", request.Variables) } _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{ "pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[]}}}`)) return } if cursor != "next" || request.Variables["first"] != float64(50) { t.Fatalf("second page variables = %#v", request.Variables) } _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{ "pageInfo":{"hasNextPage":false},"nodes":[]}}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") if _, err := client.ListPullRequests(context.Background(), "", "", 150, false); err != nil { t.Fatal(err) } if requests != 2 { t.Fatalf("requests = %d, want 2", requests) } } func TestGraphQLErrorsAreReturned(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") _, err := client.ListPullRequests(context.Background(), "o", "r", 50, false) if err == nil || !strings.Contains(err.Error(), "no access") { t.Fatalf("error = %v", err) } } func TestThreadWriteMutationsUseThreadIDs(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } input, _ := request.Variables["input"].(map[string]any) switch { case strings.Contains(request.Query, "unresolveReviewThread"): if input["threadId"] != "thread" { t.Fatalf("unresolve input = %#v", input) } _, _ = w.Write([]byte(`{"data":{"unresolveReviewThread":{"thread":{ "id":"thread","path":"a.go","isResolved":false,"viewerCanResolve":true }}}}`)) case strings.Contains(request.Query, "resolveReviewThread"): if input["threadId"] != "thread" { t.Fatalf("resolve input = %#v", input) } _, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{ "id":"thread","path":"a.go","isResolved":true,"viewerCanUnresolve":true }}}}`)) case strings.Contains(request.Query, "addPullRequestReviewThreadReply"): if input["pullRequestReviewThreadId"] != "thread" || input["body"] != "reply body" { t.Fatalf("reply input = %#v", input) } _, _ = w.Write([]byte(`{"data":{"addPullRequestReviewThreadReply":{"comment":{ "id":"comment","body":"reply body","createdAt":"2026-01-01T00:00:00Z", "url":"https://example/comment","author":{"login":"me"} }}}}`)) default: t.Fatalf("unexpected mutation: %s", request.Query) } })) defer server.Close() client := NewGitHubClient(server.URL, "secret") resolved, err := client.SetThreadResolved(context.Background(), "thread", true) if err != nil || !resolved.IsResolved || !resolved.ViewerCanUnresolve { t.Fatalf("resolve result = %#v, error = %v", resolved, err) } unresolved, err := client.SetThreadResolved(context.Background(), "thread", false) if err != nil || unresolved.IsResolved || !unresolved.ViewerCanResolve { t.Fatalf("unresolve result = %#v, error = %v", unresolved, err) } comment, err := client.ReplyToThread(context.Background(), "thread", "reply body") if err != nil || comment.ID != "comment" || comment.Author != "me" || comment.Body != "reply body" { t.Fatalf("reply result = %#v, error = %v", comment, err) } if requests != 3 { t.Fatalf("mutation requests = %d, want 3", requests) } } func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } if !strings.Contains(request.Query, "mutation UpdatePullRequest") { t.Fatalf("unexpected mutation:\n%s", request.Query) } input := request.Variables["input"].(map[string]any) if input["pullRequestId"] != "pr-id" || input["title"] != "New title" || input["body"] != "- [x] done" || input["baseRefName"] != "release" { t.Fatalf("update input = %#v", input) } _, _ = w.Write([]byte(`{"data":{"updatePullRequest":{"pullRequest":{ "id":"pr-id","title":"New title","body":"- [x] done","baseRefName":"release", "updatedAt":"2026-07-28T12:00:00Z","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN" }}}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") got, err := client.UpdatePullRequest(context.Background(), "pr-id", PullRequestMetadata{ Title: "New title", Body: "- [x] done", BaseRef: "release", }) if err != nil { t.Fatal(err) } if got.Title != "New title" || got.Body != "- [x] done" || got.BaseRef != "release" || got.Mergeable != "UNKNOWN" || got.UpdatedAt.IsZero() { t.Fatalf("updated pull request = %#v", got) } } func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } switch { case strings.Contains(request.Query, "query CheckContextsPage"): _, _ = w.Write([]byte(`{"data":{"node":{"contexts":{ "pageInfo":{"hasNextPage":false},"nodes":[{ "id":"check-2","name":"lint","conclusion":"FAILURE" }]}}}}`)) case strings.Contains(request.Query, "query CheckAnnotationsPage"): if request.Variables["after"] == nil { _, _ = w.Write([]byte(`{"data":{"node":{"annotations":{ "pageInfo":{"hasNextPage":true,"endCursor":"annotation-next"}, "nodes":[{"path":"a.go","location":{"start":{"line":4},"end":{"line":4}}, "annotationLevel":"FAILURE","message":"first"}] }}}}`)) } else { _, _ = w.Write([]byte(`{"data":{"node":{"annotations":{ "pageInfo":{"hasNextPage":false}, "nodes":[{"path":"b.go","location":{"start":{"line":8},"end":{"line":8}}, "annotationLevel":"WARNING","message":"second"}] }}}}`)) } default: t.Fatalf("unexpected query: %s", request.Query) } })) defer server.Close() client := NewGitHubClient(server.URL, "secret") nodes, err := client.allCheckContexts(context.Background(), githubCheckContextConnection{ PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "context-next"}, Nodes: []githubCheckContext{{ID: "check-1", Name: "tests", Conclusion: "SUCCESS"}}, }, "rollup") if err != nil { t.Fatal(err) } if len(nodes) != 2 || len(nodes[1].Annotations.Nodes) != 2 || nodes[1].Annotations.Nodes[0].Location.Start.Line != 4 || nodes[1].Annotations.Nodes[1].Location.End.Line != 8 { t.Fatalf("paginated checks = %#v", nodes) } } func TestCheckQueriesUseCurrentGitHubSchemaShape(t *testing.T) { for name, query := range map[string]string{"annotations": checkAnnotationsPageQuery} { if strings.Contains(query, "output {") || strings.Contains(query, "nodes { path startLine endLine annotationLevel") || !strings.Contains(query, "location { start { line column } end { line column } }") { t.Fatalf("%s query uses obsolete CheckRun/CheckAnnotation fields:\n%s", name, query) } } for name, query := range map[string]string{ "details": detailsQuery, "context page": checkContextsPageQuery, } { if strings.Contains(query, "annotations(first:") { t.Fatalf("%s query eagerly loads annotations:\n%s", name, query) } } } func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request graphQLRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } switch { case strings.Contains(request.Query, "query ReviewThreadsPage"): _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviewThreads":{ "pageInfo":{"hasNextPage":false,"endCursor":null}, "nodes":[{"id":"thread-2","path":"b.go","line":20,"diffSide":"RIGHT", "isResolved":false,"isOutdated":false,"viewerCanResolve":true, "comments":{"pageInfo":{"hasNextPage":true,"endCursor":"comment-cursor"}, "nodes":[{"id":"review-comment-2a","body":"first","createdAt":"2026-01-02T00:00:00Z"}]}}] }}}}}`)) case strings.Contains(request.Query, "query ReviewCommentsPage"): _, _ = w.Write([]byte(`{"data":{"node":{"comments":{ "pageInfo":{"hasNextPage":false,"endCursor":null}, "nodes":[{"id":"review-comment-2b","body":"second","createdAt":"2026-01-03T00:00:00Z"}] }}}}`)) case strings.Contains(request.Query, "query ConversationPage"): _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"comments":{ "totalCount":2,"pageInfo":{"hasNextPage":false,"endCursor":null}, "nodes":[{"id":"conversation-2","body":"reply","createdAt":"2026-01-03T00:00:00Z"}] }}}}}`)) case strings.Contains(request.Query, "query ReviewsPage"): _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviews":{ "pageInfo":{"hasNextPage":false,"endCursor":null}, "nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}] }}}}}`)) default: _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{ "id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z", "updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"}, "assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]}, "latestReviews":{"nodes":[]},"commits":{"totalCount":1,"nodes":[]}, "comments":{"totalCount":2,"pageInfo":{"hasNextPage":true,"endCursor":"conversation-cursor"}, "nodes":[{"id":"conversation-1","body":"start","createdAt":"2026-01-01T00:00:00Z"}]}, "reviews":{"pageInfo":{"hasNextPage":true,"endCursor":"review-cursor"}, "nodes":[{"id":"review-1","body":"changes","state":"CHANGES_REQUESTED","submittedAt":"2026-01-02T00:00:00Z"}]}, "reviewThreads":{"pageInfo":{"hasNextPage":true,"endCursor":"thread-cursor"}, "nodes":[{"id":"thread-1","path":"a.go","line":10,"diffSide":"RIGHT", "isResolved":false,"isOutdated":false, "comments":{"pageInfo":{"hasNextPage":false}, "nodes":[{"id":"review-comment-1","body":"fix","createdAt":"2026-01-01T00:00:00Z"}]}}]} }}}}`)) } })) defer server.Close() client := NewGitHubClient(server.URL, "secret") got, err := client.GetPullRequest(context.Background(), "o", "r", 1) if err != nil { t.Fatal(err) } if len(got.Threads) != 2 || len(got.Threads[1].Comments) != 2 || len(got.Conversation) != 2 || len(got.Reviews) != 2 { t.Fatalf("paginated details were incomplete: %#v", got) } if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" { t.Fatalf("permissions = %#v", got.Permissions) } } func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{ "id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false, "createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z", "mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED", "additions":12,"deletions":4,"changedFiles":3, "baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789", "viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true, "viewerCanEnableAutoMerge":true, "baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true, "requiredApprovingReviewCount":2,"requiresStatusChecks":true, "requiresConversationResolution":true,"requiresCodeOwnerReviews":true}}, "author":{"login":"zam"}, "assignees":{"nodes":[{"login":"sam"}]}, "labels":{"nodes":[{"name":"bug"},{"name":"backend"}]}, "milestone":{"title":"v2"}, "comments":{"totalCount":5}, "reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]}, "latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]}, "commits":{"totalCount":7,"nodes":[{"commit":{"oid":"abcdef0123456789", "statusCheckRollup":{"state":"FAILURE","contexts":{"nodes":[ {"name":"tests","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"https://checks/tests"}, {"context":"legacy","state":"SUCCESS","targetUrl":"https://checks/legacy"} ]}}}}]}, "reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{ "id":"t","isResolved":false,"isOutdated":true,"path":"main.go", "viewerCanResolve":true, "line":null,"originalLine":42,"diffSide":"RIGHT", "startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT", "comments":{"pageInfo":{"hasNextPage":false},"nodes":[{ "id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z", "url":"cu","author":{"login":"reviewer"},"outdated":true, "line":100,"startLine":99,"originalLine":42,"originalStartLine":40, "originalCommit":{"oid":"0123456789abcdef"}, "reactionGroups":[ {"content":"THUMBS_UP","viewerHasReacted":true,"reactors":{"totalCount":3}}, {"content":"EYES","viewerHasReacted":false,"reactors":{"totalCount":1}}, {"content":"HEART","viewerHasReacted":false,"reactors":{"totalCount":0}} ] }]} }]} }}}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") got, err := client.GetPullRequest(context.Background(), "o", "r", 9) if err != nil { t.Fatal(err) } if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" { t.Fatalf("unexpected metadata: %#v", got) } if got.MergeState != "CLEAN" || got.Additions != 12 || got.Deletions != 4 || got.ChangedFiles != 3 || got.CommitCount != 7 || got.CommentCount != 5 || got.Milestone != "v2" || strings.Join(got.Labels, ",") != "bug,backend" || got.CreatedAt.IsZero() { t.Fatalf("unexpected dashboard metadata: %#v", got) } if got.HeadOID != "abcdef0123456789" || len(got.Checks) != 2 || got.Checks[0].Name != "tests" || got.Checks[1].Name != "legacy" || got.Permissions.Repository != "WRITE" || !got.Permissions.CanUpdatePR || !got.Permissions.CanResolveAny || got.Requirements.ApprovalsRequired != 2 || !got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview { t.Fatalf("unexpected read capabilities: %#v", got) } if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 || got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated { t.Fatalf("unexpected thread: %#v", got.Threads) } comment := got.Threads[0].Comments[0] if comment.OriginalLine != 42 || comment.OriginalStartLine != 40 || comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated { t.Fatalf("unexpected comment snapshot: %#v", comment) } if len(comment.Reactions) != 2 || comment.Reactions[0] != (ReactionSummary{Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}) || comment.Reactions[1] != (ReactionSummary{Content: "EYES", Count: 1}) { t.Fatalf("unexpected comment reactions: %#v", comment.Reactions) } } func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":{"repository":{ "url":"https://github.com/o/r","pullRequest":{ "id":"pr","number":3,"title":"Conflict","url":"u", "mergeable":"CONFLICTING","baseRefName":"main","headRefName":"feature", "headRefOid":"head123","baseRef":{"target":{"oid":"base123"}}, "comments":{"pageInfo":{"hasNextPage":false}}, "reviews":{"pageInfo":{"hasNextPage":false}}, "timelineItems":{"pageInfo":{"hasNextPage":false}}, "reviewThreads":{"pageInfo":{"hasNextPage":false}} } }}}`)) })) defer server.Close() client := NewGitHubClient(server.URL, "secret") client.conflicts = func( _ context.Context, repositoryURL string, number int, baseRef, baseOID, headOID, token string, ) ([]string, error) { if repositoryURL != "https://github.com/o/r" || number != 3 || baseRef != "main" || baseOID != "base123" || headOID != "head123" || token != "secret" { t.Fatalf( "conflict loader arguments = %q, %d, %q, %q, %q, %q", repositoryURL, number, baseRef, baseOID, headOID, token, ) } return []string{"src/conflict.go", "README.md"}, nil } got, err := client.GetPullRequest(context.Background(), "o", "r", 3) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(got.ConflictFiles, []string{"src/conflict.go", "README.md"}) { t.Fatalf("conflict files = %#v", got.ConflictFiles) } }