1040 lines
36 KiB
Go
1040 lines
36 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// stubGraphQLExecutor lets tests inspect/return canned responses without
|
|
// touching HTTP or the gh CLI.
|
|
type stubGraphQLExecutor struct {
|
|
queries []string
|
|
response string
|
|
responses []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
|
|
}
|
|
response := s.response
|
|
if len(s.responses) > 0 {
|
|
response = s.responses[0]
|
|
s.responses = s.responses[1:]
|
|
}
|
|
return json.Unmarshal([]byte(response), out)
|
|
}
|
|
|
|
func resolvedReviewThreadNodes(count int) []map[string]bool {
|
|
nodes := make([]map[string]bool, count)
|
|
for i := range nodes {
|
|
nodes[i] = map[string]bool{"isResolved": true}
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func mustJSON(t *testing.T, value any) string {
|
|
t.Helper()
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("marshal GraphQL fixture: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func reviewThreadsFixture(
|
|
totalCount int,
|
|
nodes []map[string]bool,
|
|
hasNextPage bool,
|
|
endCursor string,
|
|
) map[string]any {
|
|
return map[string]any{
|
|
"totalCount": totalCount,
|
|
"nodes": nodes,
|
|
"pageInfo": map[string]any{"hasNextPage": hasNextPage, "endCursor": endCursor},
|
|
}
|
|
}
|
|
|
|
func batchedPRFixture(
|
|
title string,
|
|
url string,
|
|
totalCount int,
|
|
nodes []map[string]bool,
|
|
hasNextPage bool,
|
|
endCursor string,
|
|
) map[string]any {
|
|
return map[string]any{
|
|
"state": "OPEN", "title": title, "url": url,
|
|
"headRefName": "feat", "baseRefName": "main", "headRefOid": "abc",
|
|
"author": map[string]any{"login": "alice"},
|
|
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z",
|
|
"reviews": map[string]any{"nodes": []any{}},
|
|
"reviewRequests": map[string]any{"totalCount": 0},
|
|
"reviewThreads": reviewThreadsFixture(totalCount, nodes, hasNextPage, endCursor),
|
|
"commits": map[string]any{"nodes": []any{}},
|
|
}
|
|
}
|
|
|
|
func batchedPRReviewThreadsResponse(
|
|
t *testing.T,
|
|
totalCount int,
|
|
nodes []map[string]bool,
|
|
hasNextPage bool,
|
|
endCursor string,
|
|
) string {
|
|
t.Helper()
|
|
return mustJSON(t, map[string]any{
|
|
"data": map[string]any{
|
|
"repo0": map[string]any{
|
|
"pr0": batchedPRFixture("Busy PR", "https://x/42", totalCount, nodes, hasNextPage, endCursor),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func reviewThreadPageResponse(
|
|
t *testing.T,
|
|
nodes []map[string]bool,
|
|
hasNextPage bool,
|
|
endCursor string,
|
|
) string {
|
|
t.Helper()
|
|
return reviewThreadPagesResponse(t, reviewThreadsFixture(len(nodes), nodes, hasNextPage, endCursor))
|
|
}
|
|
|
|
func reviewThreadPagesResponse(t *testing.T, pages ...map[string]any) string {
|
|
t.Helper()
|
|
data := make(map[string]any, len(pages))
|
|
for i, page := range pages {
|
|
data[fmt.Sprintf("repo%d", i)] = map[string]any{
|
|
"pr0": map[string]any{"reviewThreads": page},
|
|
}
|
|
}
|
|
return mustJSON(t, map[string]any{
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
func batchedBranchReviewThreadsResponse(
|
|
t *testing.T,
|
|
totalCount int,
|
|
nodes []map[string]bool,
|
|
hasNextPage bool,
|
|
endCursor string,
|
|
) string {
|
|
t.Helper()
|
|
node := batchedPRFixture("Busy branch PR", "https://x/7", totalCount, nodes, hasNextPage, endCursor)
|
|
node["number"] = 7
|
|
return mustJSON(t, map[string]any{
|
|
"data": map[string]any{
|
|
"b0": map[string]any{
|
|
"pullRequests": map[string]any{
|
|
"nodes": []any{node},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestPRFieldsBlock_RequestsOutcomeFields covers AC-08: the shared field
|
|
// block requests changedFiles, mergedBy, autoMergeRequest, and a
|
|
// CLOSED_EVENT timeline selection, so both the batched PR query and the
|
|
// batched branch query return the same fields.
|
|
func TestPRFieldsBlock_RequestsOutcomeFields(t *testing.T) {
|
|
block := prFieldsBlock()
|
|
for _, want := range []string{"changedFiles", "mergedBy { login }", "autoMergeRequest", "headRepository {", "headRepositoryOwner { login }", "CLOSED_EVENT"} {
|
|
if !strings.Contains(block, want) {
|
|
t.Errorf("prFieldsBlock() missing %q: %s", want, block)
|
|
}
|
|
}
|
|
if strings.Contains(block, "closedBy") {
|
|
t.Errorf("prFieldsBlock() must not request closedBy (absent from PullRequest type): %s", block)
|
|
}
|
|
|
|
prQuery, _ := buildBatchedPRQuery([]graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}})
|
|
if !strings.Contains(prQuery, "changedFiles") || !strings.Contains(prQuery, "CLOSED_EVENT") {
|
|
t.Errorf("buildBatchedPRQuery must include the outcome fields: %s", prQuery)
|
|
}
|
|
branchQuery, _ := buildBatchedBranchQuery([]graphQLBranchRef{{Owner: "o", Repo: "r", Branch: "feat"}})
|
|
if !strings.Contains(branchQuery, "changedFiles") || !strings.Contains(branchQuery, "CLOSED_EVENT") {
|
|
t.Errorf("buildBatchedBranchQuery must include the outcome fields: %s", branchQuery)
|
|
}
|
|
}
|
|
|
|
// TestPRFieldsBlock_PinsLastOneForClosedEventOrdering is the AC-08a
|
|
// regression: closedEventActor (below) trusts the query to hand it at most
|
|
// one timelineItems node and unconditionally reads nodes[0], so "most
|
|
// recent closure" is guaranteed entirely by the GraphQL connection argument,
|
|
// not by any Go-side ordering logic. TestPRFieldsBlock_RequestsOutcomeFields
|
|
// only substring-matches "CLOSED_EVENT", which stays green if `last: 1` were
|
|
// changed to `first: N` — GitHub connection ordering is oldest-first, so
|
|
// `first: N` would silently attribute closure to the PR's FIRST close
|
|
// instead of its latest one across a reopen cycle, with every existing
|
|
// single-node test still passing. Pin the exact argument.
|
|
func TestPRFieldsBlock_PinsLastOneForClosedEventOrdering(t *testing.T) {
|
|
block := prFieldsBlock()
|
|
const want = "timelineItems(last: 1, itemTypes: CLOSED_EVENT)"
|
|
if !strings.Contains(block, want) {
|
|
t.Errorf("prFieldsBlock() = %s, want it to contain %q", block, want)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_SetsOutcomeFieldsPopulatedUnconditionally
|
|
// covers AC-10: the batched GraphQL path always marks the outcome-field
|
|
// group populated, since it always fetches a full pull request.
|
|
func TestConvertBatchedPRResult_SetsOutcomeFieldsPopulatedUnconditionally(t *testing.T) {
|
|
status := convertBatchedPRResult(&batchedPRResult{State: "OPEN"}, "o", "r", 1)
|
|
if !status.OutcomeFieldsPopulated {
|
|
t.Error("OutcomeFieldsPopulated = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_MergedByAndChangedFiles covers AC-12's upstream
|
|
// half: a merged PR with a non-null mergedBy and a real changedFiles count
|
|
// converts onto PR, not just PRStatus.
|
|
func TestConvertBatchedPRResult_MergedByAndChangedFiles(t *testing.T) {
|
|
raw := &batchedPRResult{
|
|
State: "MERGED",
|
|
MergedAt: "2026-08-12T00:00:00Z",
|
|
ChangedFiles: intPtr(8),
|
|
}
|
|
raw.MergedBy.Login = "carlosflorencio"
|
|
status := convertBatchedPRResult(raw, "kdlbs", "kandev", 2554)
|
|
if status.PR.ChangedFiles != 8 {
|
|
t.Errorf("ChangedFiles = %d, want 8", status.PR.ChangedFiles)
|
|
}
|
|
if !status.PR.ChangedFilesObserved {
|
|
t.Error("ChangedFilesObserved = false, want true for an explicit changedFiles=8")
|
|
}
|
|
if status.PR.MergedByLogin != "carlosflorencio" {
|
|
t.Errorf("MergedByLogin = %q, want carlosflorencio", status.PR.MergedByLogin)
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_MissingIsDraftAndChangedFilesLeavesUnobserved
|
|
// covers AC-12a: a batched GraphQL result that omits isDraft and
|
|
// changedFiles must decode to unobserved (Observed=false), so
|
|
// resolveTaskPROutcomeFields writes NULL for those columns instead of a
|
|
// fabricated false/0 that would masquerade as a real observation.
|
|
// TestConvertBatchedPRResult_SetsOutcomeFieldsPopulatedUnconditionally
|
|
// above proves the *group* flag stays true for this exact input — this test
|
|
// proves the two field-level flags do NOT, which is what AC-12a actually
|
|
// requires ("each field is judged on its own presence").
|
|
func TestConvertBatchedPRResult_MissingIsDraftAndChangedFilesLeavesUnobserved(t *testing.T) {
|
|
status := convertBatchedPRResult(&batchedPRResult{State: "OPEN"}, "o", "r", 1)
|
|
if status.PR.IsDraftObserved {
|
|
t.Error("IsDraftObserved = true, want false when isDraft is absent from the response")
|
|
}
|
|
if status.PR.ChangedFilesObserved {
|
|
t.Error("ChangedFilesObserved = true, want false when changedFiles is absent from the response")
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_ExplicitFalseAndZeroAreObserved covers AC-12a's
|
|
// other half: a response that genuinely reports isDraft=false and
|
|
// changedFiles=0 must be distinguishable from one that omits them — both
|
|
// mark Observed=true.
|
|
func TestConvertBatchedPRResult_ExplicitFalseAndZeroAreObserved(t *testing.T) {
|
|
status := convertBatchedPRResult(
|
|
&batchedPRResult{State: "OPEN", IsDraft: boolPtr(false), ChangedFiles: intPtr(0)}, "o", "r", 1,
|
|
)
|
|
if !status.PR.IsDraftObserved {
|
|
t.Error("IsDraftObserved = false, want true for an explicit isDraft=false")
|
|
}
|
|
if status.PR.Draft {
|
|
t.Error("Draft = true, want false")
|
|
}
|
|
if !status.PR.ChangedFilesObserved {
|
|
t.Error("ChangedFilesObserved = false, want true for an explicit changedFiles=0")
|
|
}
|
|
if status.PR.ChangedFiles != 0 {
|
|
t.Errorf("ChangedFiles = %d, want 0", status.PR.ChangedFiles)
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_ClosedEventActorPopulatesAttribution covers
|
|
// AC-14: a closed PR carrying a ClosedEvent timeline node with a non-empty
|
|
// actor login marks closure attribution populated and copies the login.
|
|
func TestConvertBatchedPRResult_ClosedEventActorPopulatesAttribution(t *testing.T) {
|
|
raw := &batchedPRResult{State: "CLOSED"}
|
|
raw.TimelineItems.Nodes = []timelineClosedEventNode{{}}
|
|
raw.TimelineItems.Nodes[0].Actor = &timelineActor{Login: "nova28"}
|
|
status := convertBatchedPRResult(raw, "kdlbs", "kandev", 2476)
|
|
if !status.ClosureAttributionPopulated {
|
|
t.Fatal("ClosureAttributionPopulated = false, want true")
|
|
}
|
|
if status.ClosedByLogin != "nova28" {
|
|
t.Errorf("ClosedByLogin = %q, want nova28", status.ClosedByLogin)
|
|
}
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_NullActorLeavesAttributionUnpopulated covers
|
|
// AC-15's decode half: no timeline node, or a node with a null actor, must
|
|
// not mark closure attribution populated.
|
|
func TestConvertBatchedPRResult_NullActorLeavesAttributionUnpopulated(t *testing.T) {
|
|
t.Run("no timeline nodes", func(t *testing.T) {
|
|
status := convertBatchedPRResult(&batchedPRResult{State: "CLOSED"}, "o", "r", 1)
|
|
if status.ClosureAttributionPopulated {
|
|
t.Error("ClosureAttributionPopulated = true, want false")
|
|
}
|
|
if status.ClosedByLogin != "" {
|
|
t.Errorf("ClosedByLogin = %q, want empty", status.ClosedByLogin)
|
|
}
|
|
})
|
|
t.Run("null actor", func(t *testing.T) {
|
|
raw := &batchedPRResult{State: "CLOSED"}
|
|
raw.TimelineItems.Nodes = []timelineClosedEventNode{{}}
|
|
status := convertBatchedPRResult(raw, "o", "r", 1)
|
|
if status.ClosureAttributionPopulated {
|
|
t.Error("ClosureAttributionPopulated = true, want false")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestConvertBatchedPRResult_ClosedEventActorReflectsLatestClosureAfterReopen
|
|
// is the AC-08a close-reopen-close regression. A PR closed by "alice",
|
|
// reopened, then closed again by "bob" must attribute closure to "bob" — the
|
|
// PR's CURRENT closure, not its first one ever. `timelineItems(last: 1, ...)`
|
|
// is what makes this true: GitHub only ever sends the single most-recent
|
|
// CLOSED_EVENT node in the slice this test constructs, so decoding it and
|
|
// reading nodes[0] (closedEventActor's actual logic) yields "bob". This
|
|
// documents the semantic the pinned `last: 1` argument
|
|
// (TestPRFieldsBlock_PinsLastOneForClosedEventOrdering) exists to guarantee.
|
|
func TestConvertBatchedPRResult_ClosedEventActorReflectsLatestClosureAfterReopen(t *testing.T) {
|
|
raw := &batchedPRResult{State: "CLOSED"}
|
|
// What GitHub returns for timelineItems(last: 1, itemTypes: CLOSED_EVENT)
|
|
// after alice's close, a reopen, then bob's close: only bob's event.
|
|
raw.TimelineItems.Nodes = []timelineClosedEventNode{
|
|
{Actor: &timelineActor{Login: "bob"}},
|
|
}
|
|
status := convertBatchedPRResult(raw, "kdlbs", "kandev", 3001)
|
|
if !status.ClosureAttributionPopulated {
|
|
t.Fatal("ClosureAttributionPopulated = false, want true")
|
|
}
|
|
if status.ClosedByLogin != "bob" {
|
|
t.Errorf("ClosedByLogin = %q, want %q (the reopen cycle's later closer, not alice's earlier close)",
|
|
status.ClosedByLogin, "bob")
|
|
}
|
|
}
|
|
|
|
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 TestNormalizeGraphQLCheckRollupState_CheckRollup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{name: "success", raw: "SUCCESS", want: checkStatusSuccess},
|
|
{name: "failure", raw: "FAILURE", want: checkConclusionFail},
|
|
{name: "error", raw: "ERROR", want: checkConclusionFail},
|
|
{name: "pending", raw: "PENDING", want: checkStatusPending},
|
|
{name: "expected", raw: "EXPECTED", want: checkStatusPending},
|
|
{name: "empty", raw: "", want: ""},
|
|
{name: "unknown", raw: "NEUTRAL", want: ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := normalizeGraphQLCheckRollupState(tt.raw); got != tt.want {
|
|
t.Fatalf("normalizeGraphQLCheckRollupState(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_PaginatesResolvedReviewThreads(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 102, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(2), false, "cursor-2"),
|
|
}}
|
|
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
|
|
status := got[prStatusCacheKey("o", "r", 42)]
|
|
if status.UnresolvedReviewThreads != 0 {
|
|
t.Fatalf("UnresolvedReviewThreads = %d, want 0", status.UnresolvedReviewThreads)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_BatchesReviewThreadContinuations(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
mustJSON(t, map[string]any{
|
|
"data": map[string]any{
|
|
"repo0": map[string]any{
|
|
"pr0": batchedPRFixture("PR A", "https://x/a/1", 101, resolvedReviewThreadNodes(100), true, "cursor-a"),
|
|
},
|
|
"repo1": map[string]any{
|
|
"pr0": batchedPRFixture("PR B", "https://x/b/2", 101, resolvedReviewThreadNodes(100), true, "cursor-b"),
|
|
},
|
|
},
|
|
}),
|
|
reviewThreadPagesResponse(t,
|
|
reviewThreadsFixture(1, []map[string]bool{{"isResolved": false}}, false, "cursor-a2"),
|
|
reviewThreadsFixture(1, resolvedReviewThreadNodes(1), false, "cursor-b2"),
|
|
),
|
|
}}
|
|
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "a", Number: 1},
|
|
{Owner: "o", Repo: "b", Number: 2},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(exec.queries) != 2 {
|
|
t.Fatalf("GraphQL query count = %d, want 2", len(exec.queries))
|
|
}
|
|
if !strings.Contains(exec.queries[0], `pageInfo { hasNextPage endCursor }`) {
|
|
t.Errorf("initial query does not request review-thread pageInfo: %s", exec.queries[0])
|
|
}
|
|
if !strings.Contains(exec.queries[1], `query ReviewThreadPages`) ||
|
|
!strings.Contains(exec.queries[1], `after: "cursor-a"`) ||
|
|
!strings.Contains(exec.queries[1], `after: "cursor-b"`) {
|
|
t.Errorf("follow-up query does not batch both cursors: %s", exec.queries[1])
|
|
}
|
|
if got[prStatusCacheKey("o", "a", 1)].UnresolvedReviewThreads != 1 {
|
|
t.Errorf("o/a#1 unresolved count = %d, want 1", got[prStatusCacheKey("o", "a", 1)].UnresolvedReviewThreads)
|
|
}
|
|
if got[prStatusCacheKey("o", "b", 2)].UnresolvedReviewThreads != 0 {
|
|
t.Errorf("o/b#2 unresolved count = %d, want 0", got[prStatusCacheKey("o", "b", 2)].UnresolvedReviewThreads)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_BoundsReviewThreadContinuationBatchSize(t *testing.T) {
|
|
initialData := make(map[string]any, 6)
|
|
refs := make([]graphQLPRRef, 6)
|
|
pages := make([]map[string]any, 6)
|
|
for i := range refs {
|
|
repo := fmt.Sprintf("repo-%d", i)
|
|
initialData[fmt.Sprintf("repo%d", i)] = map[string]any{
|
|
"pr0": batchedPRFixture(
|
|
fmt.Sprintf("PR %d", i), fmt.Sprintf("https://x/%d", i),
|
|
101, resolvedReviewThreadNodes(100), true, fmt.Sprintf("cursor-%d", i),
|
|
),
|
|
}
|
|
refs[i] = graphQLPRRef{Owner: "o", Repo: repo, Number: i + 1}
|
|
pages[i] = reviewThreadsFixture(1, resolvedReviewThreadNodes(1), false, "done")
|
|
}
|
|
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
mustJSON(t, map[string]any{"data": initialData}),
|
|
reviewThreadPagesResponse(t, pages...),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(1), false, "done"),
|
|
}}
|
|
|
|
if _, err := runBatchedPRQuery(context.Background(), exec, refs); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(exec.queries) != 3 {
|
|
t.Fatalf("GraphQL query count = %d, want 3", len(exec.queries))
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_OneReviewThreadPageSkipsContinuation(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{
|
|
response: batchedPRReviewThreadsResponse(
|
|
t, 2, []map[string]bool{{"isResolved": true}, {"isResolved": false}}, false, "cursor-1",
|
|
),
|
|
}
|
|
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(exec.queries) != 1 {
|
|
t.Fatalf("GraphQL query count = %d, want 1", len(exec.queries))
|
|
}
|
|
if got[prStatusCacheKey("o", "r", 42)].UnresolvedReviewThreads != 1 {
|
|
t.Fatalf("UnresolvedReviewThreads = %d, want 1", got[prStatusCacheKey("o", "r", 42)].UnresolvedReviewThreads)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_RejectsRepeatedReviewThreadCursor(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 201, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(1), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(1), false, "cursor-2"),
|
|
}}
|
|
|
|
_, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected repeated review-thread cursor to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "repeated cursor") {
|
|
t.Fatalf("error = %q, want repeated-cursor failure", err)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_RejectsPagesBeyondInitialTotalCount(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 101, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(1), true, "cursor-2"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(1), false, "cursor-3"),
|
|
}}
|
|
|
|
_, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected review-thread pages beyond initial totalCount to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "page limit") {
|
|
t.Fatalf("error = %q, want page-limit failure", err)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_PreservesMissingReposOnReviewThreadPaginationFailure(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
mustJSON(t, map[string]any{
|
|
"data": map[string]any{
|
|
"repo1": map[string]any{
|
|
"pr0": batchedPRFixture(
|
|
"Busy PR", "https://x/live/42", 101,
|
|
resolvedReviewThreadNodes(100), true, "cursor-1",
|
|
),
|
|
},
|
|
},
|
|
"errors": []map[string]any{{
|
|
"message": "Could not resolve to a Repository with the name 'o/dead'.",
|
|
"type": "NOT_FOUND",
|
|
"path": []string{"repo0"},
|
|
}},
|
|
}),
|
|
`{"data": {}}`,
|
|
}}
|
|
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "dead", Number: 1},
|
|
{Owner: "o", Repo: "live", Number: 42},
|
|
})
|
|
if got != nil {
|
|
t.Fatalf("status map = %#v, want nil after incomplete pagination", got)
|
|
}
|
|
var missingErr *batchedMissingReposErr
|
|
if !errors.As(err, &missingErr) {
|
|
t.Fatalf("error = %v, want batchedMissingReposErr", err)
|
|
}
|
|
if len(missingErr.Repos) != 1 || missingErr.Repos[0] != (repoRef{Owner: "o", Repo: "dead"}) {
|
|
t.Fatalf("missing repos = %#v, want o/dead", missingErr.Repos)
|
|
}
|
|
if missingErr.Inner == nil || !strings.Contains(missingErr.Inner.Error(), "missing repository alias") {
|
|
t.Fatalf("inner error = %v, want pagination failure", missingErr.Inner)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_RejectsEmptyReviewThreadCursor(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responses []string
|
|
}{
|
|
{
|
|
name: "initial page",
|
|
responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 101, resolvedReviewThreadNodes(100), true, ""),
|
|
},
|
|
},
|
|
{
|
|
name: "continuation page",
|
|
responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 201, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, resolvedReviewThreadNodes(100), true, ""),
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: tt.responses}
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err == nil || got != nil {
|
|
t.Fatalf("got (%v, %v), want (nil, error)", got, err)
|
|
}
|
|
if !strings.Contains(err.Error(), "empty cursor") {
|
|
t.Fatalf("error = %q, want empty-cursor failure", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_PaginatesMultipleReviewThreadRounds(t *testing.T) {
|
|
secondPage := append(
|
|
[]map[string]bool{{"isResolved": false}},
|
|
resolvedReviewThreadNodes(99)...,
|
|
)
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 202, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
reviewThreadPageResponse(t, secondPage, true, "cursor-2"),
|
|
reviewThreadPageResponse(t, []map[string]bool{
|
|
{"isResolved": false},
|
|
{"isResolved": true},
|
|
}, false, "cursor-3"),
|
|
}}
|
|
|
|
got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(exec.queries) != 3 {
|
|
t.Fatalf("GraphQL query count = %d, want 3", len(exec.queries))
|
|
}
|
|
if got[prStatusCacheKey("o", "r", 42)].UnresolvedReviewThreads != 2 {
|
|
t.Fatalf("UnresolvedReviewThreads = %d, want 2", got[prStatusCacheKey("o", "r", 42)].UnresolvedReviewThreads)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_RejectsNullReviewThreadPage(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{responses: []string{
|
|
batchedPRReviewThreadsResponse(t, 101, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
|
mustJSON(t, map[string]any{
|
|
"data": map[string]any{
|
|
"repo0": map[string]any{
|
|
"pr0": map[string]any{"reviewThreads": nil},
|
|
},
|
|
},
|
|
}),
|
|
}}
|
|
|
|
_, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{
|
|
{Owner: "o", Repo: "r", Number: 42},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected null review-thread page to fail")
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedPRQuery_DecodesMultipleReposIntoCorrectKeys(t *testing.T) {
|
|
// Two repos in one batch: check each aliased PR lands in its correct key.
|
|
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.Statuses[graphqlBranchKey("o", "r", "feat")]
|
|
if !ok {
|
|
t.Fatalf("expected branch result, got keys: %v", keysOf(got.Statuses))
|
|
}
|
|
if status.PR == nil || status.PR.Number != 7 {
|
|
t.Errorf("expected PR number 7, got %#v", status.PR)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchedBranchQuery_SkipsUnusedReviewThreadPagination(t *testing.T) {
|
|
exec := &stubGraphQLExecutor{response: batchedBranchReviewThreadsResponse(t, 101, resolvedReviewThreadNodes(100), true, "cursor-1")}
|
|
|
|
got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{
|
|
{Owner: "o", Repo: "r", Branch: "feat"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
|
|
status := got.Statuses[graphqlBranchKey("o", "r", "feat")]
|
|
if status == nil || status.PR == nil || status.PR.Number != 7 {
|
|
t.Fatalf("branch status = %#v, want discovered PR #7", status)
|
|
}
|
|
if len(exec.queries) != 1 {
|
|
t.Fatalf("GraphQL query count = %d, want 1", len(exec.queries))
|
|
}
|
|
}
|
|
|
|
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.Statuses) != 0 {
|
|
t.Fatalf("expected no branch result, got %#v", got.Statuses)
|
|
}
|
|
// Zero nodes is a definitive "no open PR on this branch"; callers rely on
|
|
// it to skip a redundant per-watch lookup.
|
|
if _, ok := got.ResolvedEmpty[graphqlBranchKey("o", "r", "feat")]; !ok {
|
|
t.Fatalf("expected branch to be recorded as definitively empty, got %#v", got.ResolvedEmpty)
|
|
}
|
|
}
|
|
|
|
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.Statuses) != 0 {
|
|
t.Fatalf("expected ambiguous fork heads to be skipped, got %#v", got.Statuses)
|
|
}
|
|
// Ambiguity is not a definitive negative — the caller must keep its
|
|
// per-watch fallback rather than conclude there is no PR.
|
|
if _, ok := got.ResolvedEmpty[graphqlBranchKey("o", "r", "feat")]; ok {
|
|
t.Fatal("ambiguous branch must not be recorded as definitively empty")
|
|
}
|
|
}
|
|
|
|
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.Statuses) != 0 {
|
|
t.Fatalf("expected duplicate branch matches to be skipped, got %#v", got.Statuses)
|
|
}
|
|
if _, ok := got.ResolvedEmpty[graphqlBranchKey("o", "r", "feat")]; ok {
|
|
t.Fatal("ambiguous branch must not be recorded as definitively empty")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|