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

1101 lines
34 KiB
Go

package github
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
)
// setupPollerTest creates a Poller backed by an in-memory SQLite store and MockClient.
// The tasks table is created so the JOIN in ListActivePRWatches works; tests that
// want a watch to appear in that listing must seed a matching task row via seedTask.
func setupPollerTest(t *testing.T) (*Poller, *Service, *MockClient, *Store) {
t.Helper()
rawDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
rawDB.SetMaxOpenConns(1)
rawDB.SetMaxIdleConns(1)
sqlxDB := sqlx.NewDb(rawDB, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
// Minimal tasks schema: ListActivePRWatches only joins on id and filters by archived_at.
if _, err := sqlxDB.Exec(`
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
archived_at DATETIME
)`); err != nil {
t.Fatalf("create tasks table: %v", err)
}
store, err := NewStore(sqlxDB, sqlxDB)
if err != nil {
t.Fatalf("new store: %v", err)
}
mockClient := NewMockClient()
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
eventBus := bus.NewMemoryEventBus(log)
svc := NewService(mockClient, "pat", nil, store, eventBus, log)
poller := NewPoller(svc, eventBus, log)
return poller, svc, mockClient, store
}
// seedTask inserts a minimal task row for JOIN-based queries. Pass archived=true
// to seed an archived task (archived_at set to now).
func seedTask(t *testing.T, store *Store, taskID string, archived bool) {
t.Helper()
var archivedAt interface{}
if archived {
archivedAt = time.Now().UTC()
}
if _, err := store.db.Exec(`INSERT INTO tasks (id, archived_at) VALUES (?, ?)`, taskID, archivedAt); err != nil {
t.Fatalf("seed task %s: %v", taskID, err)
}
}
func TestCheckSinglePRWatch_MergedPR_SyncsThenResets(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up a merged PR in the mock client.
now := time.Now().UTC()
mergedAt := now.Add(-1 * time.Hour)
mockClient.AddPR(&PR{
Number: 42,
Title: "Feature PR",
State: prStateMerged,
HeadSHA: "abc123",
HeadBranch: "feature-branch",
RepoOwner: "owner",
RepoName: "repo",
MergedAt: &mergedAt,
})
// Create a PRWatch in the DB.
watch := &PRWatch{
SessionID: "sess-1",
TaskID: "task-1",
Owner: "owner",
Repo: "repo",
PRNumber: 42,
Branch: "feature-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record so SyncTaskPR has something to update.
taskPR := &TaskPR{
TaskID: "task-1",
Owner: "owner",
Repo: "repo",
PRNumber: 42,
PRURL: "https://github.com/owner/repo/pull/42",
PRTitle: "Feature PR",
HeadBranch: "feature-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: TaskPR record should be updated with state="merged".
updatedTP, err := store.GetTaskPR(ctx, "task-1")
if err != nil {
t.Fatalf("get task PR: %v", err)
}
if updatedTP == nil {
t.Fatal("expected task PR to exist after sync")
}
if updatedTP.State != prStateMerged {
t.Errorf("expected task PR state=%q, got %q", prStateMerged, updatedTP.State)
}
if updatedTP.MergedAt == nil {
t.Error("expected task PR MergedAt to be set")
}
// Assert: PRWatch should be reset to pr_number=0 (still present so the
// poller can discover a follow-up PR on the same branch).
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-1")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch == nil {
t.Fatal("expected PR watch to remain after merged PR (reset, not deleted)")
}
if remainingWatch.PRNumber != 0 {
t.Errorf("expected PR watch pr_number=0 after merge, got %d", remainingWatch.PRNumber)
}
}
func TestCheckSinglePRWatch_OpenPR_SyncsOnChange(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up an open PR with a passing check.
mockClient.AddPR(&PR{
Number: 10,
Title: "Open PR",
State: "open",
HeadSHA: "def456",
HeadBranch: "open-branch",
RepoOwner: "owner",
RepoName: "repo",
Additions: 5,
Deletions: 3,
})
mockClient.AddCheckRuns("owner", "repo", "def456", []CheckRun{
{Name: "ci", Status: "completed", Conclusion: "success"},
})
// Create a PRWatch with a different last_check_status to trigger hasNew.
watch := &PRWatch{
SessionID: "sess-2",
TaskID: "task-2",
Owner: "owner",
Repo: "repo",
PRNumber: 10,
Branch: "open-branch",
LastCheckStatus: "pending", // different from "success" -> hasNew=true
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record.
taskPR := &TaskPR{
TaskID: "task-2",
Owner: "owner",
Repo: "repo",
PRNumber: 10,
PRURL: "https://github.com/owner/repo/pull/10",
PRTitle: "Open PR",
HeadBranch: "open-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: TaskPR should be synced with latest data.
updatedTP, err := store.GetTaskPR(ctx, "task-2")
if err != nil {
t.Fatalf("get task PR: %v", err)
}
if updatedTP == nil {
t.Fatal("expected task PR to exist")
}
if updatedTP.State != "open" {
t.Errorf("expected state=open, got %q", updatedTP.State)
}
if updatedTP.ChecksState != "success" {
t.Errorf("expected checks_state=success, got %q", updatedTP.ChecksState)
}
if updatedTP.Additions != 5 {
t.Errorf("expected additions=5, got %d", updatedTP.Additions)
}
// Assert: PRWatch should NOT be deleted (PR is still open).
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-2")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch == nil {
t.Error("expected PR watch to still exist for open PR")
}
}
// mockTaskBranchProvider implements TaskBranchProvider for testing.
type mockTaskBranchProvider struct {
tasks []TaskBranchInfo
err error
branches map[string]string // sessionID -> branch
}
func (m *mockTaskBranchProvider) ListTasksNeedingPRWatch(_ context.Context) ([]TaskBranchInfo, error) {
return m.tasks, m.err
}
func (m *mockTaskBranchProvider) ResolveBranchForSession(_ context.Context, _, sessionID string) string {
if m.branches != nil {
return m.branches[sessionID]
}
return ""
}
func TestReconcileWatches_CreatesWatchesForTasks(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
prov := &mockTaskBranchProvider{
tasks: []TaskBranchInfo{
{TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"},
{TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"},
},
}
poller.SetTaskBranchProvider(prov)
poller.reconcileWatches(ctx)
// Verify watches were created for both sessions.
w1, err := store.GetPRWatchBySession(ctx, "s1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w1 == nil {
t.Fatal("expected PR watch for session s1")
}
if w1.Branch != "feature-a" {
t.Errorf("expected branch %q, got %q", "feature-a", w1.Branch)
}
if w1.TaskID != "t1" {
t.Errorf("expected task_id %q, got %q", "t1", w1.TaskID)
}
w2, err := store.GetPRWatchBySession(ctx, "s2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w2 == nil {
t.Fatal("expected PR watch for session s2")
}
if w2.Branch != "feature-b" {
t.Errorf("expected branch %q, got %q", "feature-b", w2.Branch)
}
}
func TestReconcileWatches_NilProvider(t *testing.T) {
poller, _, _, _ := setupPollerTest(t)
ctx := context.Background()
// Should not panic when provider is nil.
poller.reconcileWatches(ctx)
}
func TestReconcileWatches_SkipsExistingWatches(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
// Pre-create a watch for s1.
existing := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 0,
Branch: "feature-a",
}
if err := store.CreatePRWatch(ctx, existing); err != nil {
t.Fatalf("create PR watch: %v", err)
}
prov := &mockTaskBranchProvider{
tasks: []TaskBranchInfo{
{TaskID: "t1", SessionID: "s1", Owner: "myorg", Repo: "myrepo", Branch: "feature-a"},
{TaskID: "t2", SessionID: "s2", Owner: "myorg", Repo: "myrepo", Branch: "feature-b"},
},
}
poller.SetTaskBranchProvider(prov)
poller.reconcileWatches(ctx)
// s1 should still have its original watch (EnsurePRWatch is idempotent).
w1, _ := store.GetPRWatchBySession(ctx, "s1")
if w1 == nil {
t.Fatal("expected PR watch for session s1")
}
if w1.ID != existing.ID {
t.Errorf("expected original watch ID %q, got %q", existing.ID, w1.ID)
}
// s2 should have a new watch.
w2, _ := store.GetPRWatchBySession(ctx, "s2")
if w2 == nil {
t.Fatal("expected PR watch for session s2")
}
}
func TestCheckSinglePRWatch_OpenPR_NoChange_NoSync(t *testing.T) {
poller, _, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Set up an open PR with a passing check.
mockClient.AddPR(&PR{
Number: 20,
Title: "Stable PR",
State: "open",
HeadSHA: "ghi789",
HeadBranch: "stable-branch",
RepoOwner: "owner",
RepoName: "repo",
})
mockClient.AddCheckRuns("owner", "repo", "ghi789", []CheckRun{
{Name: "ci", Status: "completed", Conclusion: "success"},
})
// Create a PRWatch with matching last_check_status -> no change.
watch := &PRWatch{
SessionID: "sess-3",
TaskID: "task-3",
Owner: "owner",
Repo: "repo",
PRNumber: 20,
Branch: "stable-branch",
LastCheckStatus: "success", // same -> hasNew=false
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Create a TaskPR record.
taskPR := &TaskPR{
TaskID: "task-3",
Owner: "owner",
Repo: "repo",
PRNumber: 20,
PRURL: "https://github.com/owner/repo/pull/20",
PRTitle: "Stable PR",
HeadBranch: "stable-branch",
BaseBranch: "main",
State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Act
poller.checkSinglePRWatch(ctx, watch)
// Assert: PRWatch should NOT be deleted.
remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-3")
if err != nil {
t.Fatalf("get PR watch: %v", err)
}
if remainingWatch == nil {
t.Error("expected PR watch to still exist")
}
}
func TestRefreshStaleBranches_UpdatesBranchWhenChanged(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
// Create a watch with pr_number=0 on old branch.
seedTask(t, store, "t1", false)
watch := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 0,
Branch: "old-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Provider resolves a different branch for this session.
prov := &mockTaskBranchProvider{
branches: map[string]string{
"s1": "new-branch",
},
}
poller.SetTaskBranchProvider(prov)
poller.refreshStaleBranches(ctx)
// Verify the watch branch was updated.
updated, err := store.GetPRWatchBySession(ctx, "s1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil {
t.Fatal("expected PR watch to exist")
}
if updated.Branch != "new-branch" {
t.Errorf("expected branch %q, got %q", "new-branch", updated.Branch)
}
}
func TestRefreshStaleBranches_SkipsWhenBranchUnchanged(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
seedTask(t, store, "t1", false)
watch := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 0,
Branch: "same-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
prov := &mockTaskBranchProvider{
branches: map[string]string{
"s1": "same-branch",
},
}
poller.SetTaskBranchProvider(prov)
poller.refreshStaleBranches(ctx)
updated, _ := store.GetPRWatchBySession(ctx, "s1")
if updated.Branch != "same-branch" {
t.Errorf("expected branch unchanged, got %q", updated.Branch)
}
}
func TestRefreshStaleBranches_SkipsWatchesWithPR(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
// Watch that already found a PR (pr_number > 0).
seedTask(t, store, "t1", false)
watch := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 42,
Branch: "old-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
prov := &mockTaskBranchProvider{
branches: map[string]string{
"s1": "new-branch",
},
}
poller.SetTaskBranchProvider(prov)
poller.refreshStaleBranches(ctx)
// Branch should NOT be updated because PR was already found.
updated, _ := store.GetPRWatchBySession(ctx, "s1")
if updated.Branch != "old-branch" {
t.Errorf("expected branch unchanged for PR watch, got %q", updated.Branch)
}
}
func TestRefreshStaleBranches_SkipsWhenResolverReturnsEmpty(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
seedTask(t, store, "t1", false)
watch := &PRWatch{
SessionID: "s1",
TaskID: "t1",
Owner: "myorg",
Repo: "myrepo",
PRNumber: 0,
Branch: "old-branch",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
// Provider returns empty string (can't resolve).
prov := &mockTaskBranchProvider{
branches: map[string]string{},
}
poller.SetTaskBranchProvider(prov)
poller.refreshStaleBranches(ctx)
updated, _ := store.GetPRWatchBySession(ctx, "s1")
if updated.Branch != "old-branch" {
t.Errorf("expected branch unchanged when resolver returns empty, got %q", updated.Branch)
}
}
// graphQLMockClient wraps MockClient with a canned GraphQL executor so tests
// exercise the batched poll path without hitting the network.
type graphQLMockClient struct {
*MockClient
prResponses []string // FIFO; one entry consumed per ExecuteGraphQL call carrying "Batch"
branchResponses []string // FIFO; consumed for "Branches" queries
prErr error // returned for the next "Batch" call
branchErr error // returned for the next "Branches" call
prQueries []string
branchQueries []string
// onExecute, if set, is called at the top of every ExecuteGraphQL before
// canned responses are consumed. Used by the singleflight coalescing
// test to block the first in-flight call while a second one races.
onExecute func()
}
func (m *graphQLMockClient) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error {
if m.onExecute != nil {
m.onExecute()
}
if strings.Contains(query, "query Branches") {
m.branchQueries = append(m.branchQueries, query)
if m.branchErr != nil {
return m.branchErr
}
if len(m.branchResponses) == 0 {
return errors.New("no canned branch response")
}
resp := m.branchResponses[0]
m.branchResponses = m.branchResponses[1:]
return json.Unmarshal([]byte(resp), out)
}
m.prQueries = append(m.prQueries, query)
if m.prErr != nil {
return m.prErr
}
if len(m.prResponses) == 0 {
return errors.New("no canned PR response")
}
resp := m.prResponses[0]
m.prResponses = m.prResponses[1:]
return json.Unmarshal([]byte(resp), out)
}
func setupBatchedPollerTest(t *testing.T) (*Poller, *Service, *graphQLMockClient, *Store) {
t.Helper()
poller, svc, mockClient, store := setupPollerTest(t)
wrapped := &graphQLMockClient{MockClient: mockClient}
svc.client = wrapped
return poller, svc, wrapped, store
}
func TestTryBatchedPRWatchCheck_NumberedWatch_AppliesStatus(t *testing.T) {
poller, _, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
gh.prResponses = []string{`{
"data": {
"repo0": {
"pr0": {
"state": "OPEN", "title": "Test PR", "url": "https://x/1",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "abc",
"author": {"login": "alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z",
"reviews": {"nodes": [{"state": "APPROVED", "author": {"login": "bob"}, "submittedAt": "2026-01-02T00:00:00Z"}]},
"reviewRequests": {"totalCount": 0},
"commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}
}
}
}
}`}
watch := &PRWatch{
SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, Branch: "feat",
LastCheckStatus: "pending", LastReviewState: "",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
taskPR := &TaskPR{
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42,
PRURL: "https://x/1", PRTitle: "Test PR", HeadBranch: "feat", BaseBranch: "main", State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) {
t.Fatalf("expected batched path to succeed")
}
updated, err := store.GetTaskPR(ctx, "t1")
if err != nil || updated == nil {
t.Fatalf("get task PR: err=%v, pr=%v", err, updated)
}
if updated.ChecksState != "success" {
t.Errorf("ChecksState = %q, want success", updated.ChecksState)
}
if updated.ReviewState != "approved" {
t.Errorf("ReviewState = %q, want approved", updated.ReviewState)
}
}
func TestTryBatchedPRWatchCheck_SearchingWatch_DetectsPR(t *testing.T) {
poller, _, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
gh.branchResponses = []string{`{
"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": []}
}]
}
}
}
}`}
watch := &PRWatch{
SessionID: "s2", TaskID: "t2", Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) {
t.Fatalf("expected batched path to succeed")
}
updated, err := store.GetPRWatchBySession(ctx, "s2")
if err != nil || updated == nil {
t.Fatalf("get PR watch: err=%v, w=%v", err, updated)
}
if updated.PRNumber != 7 {
t.Errorf("PRNumber = %d, want 7 (PR should be detected and recorded)", updated.PRNumber)
}
}
func TestTryBatchedPRWatchCheck_FallsBackOnUnsupportedClient(t *testing.T) {
// MockClient does not implement GraphQLExecutor, so the batched path must
// return false to trigger per-watch fallback.
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
watch := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, Branch: "feat"}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
if poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) {
t.Errorf("expected false when client does not implement GraphQLExecutor")
}
}
// TestSyncWatchesBatched_ManyWatches_TwoGraphQLCalls is the regression test
// for the OS-freeze caused by per-watch gh subprocess fan-out. A workspace
// with N numbered + M searching watches must fire exactly TWO GraphQL
// queries (one numbered, one searching), regardless of N or M — that's
// what keeps the gh subprocess burst bounded.
func TestSyncWatchesBatched_ManyWatches_TwoGraphQLCalls(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
// Canned responses: one PR alias per numbered watch, one branch alias per
// searching watch. Hand-rolled minimal payloads so the assertion stays on
// "did we batch?" rather than on the field decoder.
gh.prResponses = []string{`{"data":{
"repo0":{"pr0":{"state":"OPEN","title":"a","url":"x","headRefName":"a","baseRefName":"main","headRefOid":"1","author":{"login":"x"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[]}}},
"repo1":{"pr0":{"state":"OPEN","title":"b","url":"x","headRefName":"b","baseRefName":"main","headRefOid":"2","author":{"login":"x"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[]}}},
"repo2":{"pr0":{"state":"OPEN","title":"c","url":"x","headRefName":"c","baseRefName":"main","headRefOid":"3","author":{"login":"x"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[]}}}
}}`}
gh.branchResponses = []string{`{"data":{
"b0":{"pullRequests":{"nodes":[]}},
"b1":{"pullRequests":{"nodes":[]}},
"b2":{"pullRequests":{"nodes":[]}},
"b3":{"pullRequests":{"nodes":[]}}
}}`}
var watches []*PRWatch
for i := 0; i < 3; i++ {
w := &PRWatch{SessionID: "n" + string(rune('a'+i)), TaskID: "tn" + string(rune('a'+i)),
Owner: "o", Repo: "r", PRNumber: i + 1, Branch: "n"}
if err := store.CreatePRWatch(ctx, w); err != nil {
t.Fatalf("create numbered: %v", err)
}
watches = append(watches, w)
}
for i := 0; i < 4; i++ {
w := &PRWatch{SessionID: "s" + string(rune('a'+i)), TaskID: "ts" + string(rune('a'+i)),
Owner: "o", Repo: "r", PRNumber: 0, Branch: "s" + string(rune('a'+i))}
if err := store.CreatePRWatch(ctx, w); err != nil {
t.Fatalf("create searching: %v", err)
}
watches = append(watches, w)
}
if _, err := svc.SyncWatchesBatched(ctx, watches); err != nil {
t.Fatalf("SyncWatchesBatched: %v", err)
}
// THE assertion: 7 watches → exactly 1 numbered query + 1 branch query.
// Pre-fix this would have been 7 separate gh subprocess invocations.
if got := len(gh.prQueries); got != 1 {
t.Errorf("numbered GraphQL calls = %d, want 1 (all numbered watches must batch)", got)
}
if got := len(gh.branchQueries); got != 1 {
t.Errorf("branch GraphQL calls = %d, want 1 (all searching watches must batch)", got)
}
}
func TestSyncWatchesBatched_NumberedQueryError_ReturnsError(t *testing.T) {
_, svc, gh, _ := setupBatchedPollerTest(t)
ctx := context.Background()
gh.prErr = errors.New("graphql 500")
numbered := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 1}}
got, err := svc.SyncWatchesBatched(ctx, numbered)
if err == nil || got != nil {
t.Errorf("expected (nil, err) on numbered query error, got (%v, %v)", got, err)
}
}
func TestSyncWatchesBatched_BranchQueryError_ReturnsError(t *testing.T) {
// Greptile P2 (preserved from previous fetchBatchedStatuses test): when only
// the branch query fails (no numbered watches), an empty non-nil map
// silently absorbed the failure. The fix returns a non-nil error so the
// caller falls back per-watch.
_, svc, gh, _ := setupBatchedPollerTest(t)
ctx := context.Background()
gh.branchErr = errors.New("graphql 500")
searching := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat"}}
got, err := svc.SyncWatchesBatched(ctx, searching)
if err == nil || got != nil {
t.Errorf("expected (nil, err) on branch query error, got (%v, %v)", got, err)
}
}
func TestSyncWatchesBatched_MergedPR_ResetsWatch(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
watch := &PRWatch{
SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, Branch: "feat",
}
if err := store.CreatePRWatch(ctx, watch); err != nil {
t.Fatalf("create PR watch: %v", err)
}
taskPR := &TaskPR{
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99,
PRURL: "https://x/99", PRTitle: "Merged PR", HeadBranch: "feat", BaseBranch: "main", State: "open",
}
if err := store.CreateTaskPR(ctx, taskPR); err != nil {
t.Fatalf("create task PR: %v", err)
}
// Canned merged-PR response — convertBatchedPRResult promotes state to
// "merged" whenever mergedAt is non-empty, which is what triggers the
// watch reset.
gh.prResponses = []string{`{
"data": {
"repo0": {
"pr0": {
"state": "MERGED", "title": "Merged PR", "url": "https://x/99",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feat", "baseRefName": "main", "headRefOid": "abc",
"author": {"login": "alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z",
"mergedAt": "2026-01-02T00:00:00Z",
"reviews": {"nodes": []},
"reviewRequests": {"totalCount": 0},
"commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}
}
}
}
}`}
if _, err := svc.SyncWatchesBatched(ctx, []*PRWatch{watch}); err != nil {
t.Fatalf("SyncWatchesBatched: %v", err)
}
updated, err := store.GetPRWatchBySession(ctx, "s1")
if err != nil || updated == nil {
t.Fatalf("get PR watch: err=%v, w=%v", err, updated)
}
if updated.PRNumber != 0 {
t.Errorf("expected watch reset to PRNumber=0 after merge, got %d", updated.PRNumber)
}
}
// TestRefreshStaleWorkspaceWatches_CoalescesConcurrentCalls regression-tests
// the singleflight guard on per-workspace background refresh. Without it,
// the frontend's repeated WS polls (every ~5s) stack background goroutines
// that each fire a batched GraphQL request, defeating the per-process
// throttle once the cap is small enough to queue.
func TestRefreshStaleWorkspaceWatches_CoalescesConcurrentCalls(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
// Seed two tasks with one searching watch each. Searching (PRNumber=0)
// means refreshStaleWorkspaceWatches drives them through the branch
// query path, which is what onExecute will block on.
for _, id := range []string{"t1", "t2"} {
w := &PRWatch{SessionID: "sess-" + id, TaskID: id, Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-" + id}
if err := store.CreatePRWatch(ctx, w); err != nil {
t.Fatalf("create watch: %v", err)
}
}
// Two canned branch responses so neither the first NOR a hypothetical
// duplicate refresh starves on missing data — we want the assertion to
// be "duplicate never ran", not "duplicate failed".
gh.branchResponses = []string{
`{"data":{"b0":{"pullRequests":{"nodes":[]}},"b1":{"pullRequests":{"nodes":[]}}}}`,
`{"data":{"b0":{"pullRequests":{"nodes":[]}},"b1":{"pullRequests":{"nodes":[]}}}}`,
}
started := make(chan struct{}, 4)
release := make(chan struct{})
gh.onExecute = func() {
started <- struct{}{}
<-release
}
staleTasks := map[string]struct{}{"t1": {}, "t2": {}}
// Kick off the first refresh and wait until it's actually inside ExecuteGraphQL.
svc.refreshStaleWorkspaceWatches("ws1", staleTasks)
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatalf("first refresh never entered ExecuteGraphQL")
}
// Fire a second refresh for the SAME workspace while the first is blocked.
// The singleflight guard must drop it on the floor — no new goroutine, no
// new GraphQL call. A small grace period lets a buggy unguarded goroutine
// reach ExecuteGraphQL if it were going to.
svc.refreshStaleWorkspaceWatches("ws1", staleTasks)
select {
case <-started:
t.Fatalf("second refresh entered ExecuteGraphQL while first was in flight — coalescing broken")
case <-time.After(100 * time.Millisecond):
}
// Release the first and confirm post-completion refreshes work again.
close(release)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, busy := svc.inflightWorkspaceRefreshes.Load("ws1"); !busy {
break
}
time.Sleep(5 * time.Millisecond)
}
if _, busy := svc.inflightWorkspaceRefreshes.Load("ws1"); busy {
t.Fatalf("inflight key for ws1 never cleared after refresh completion")
}
// A fresh refresh must start (key was cleared in the defer).
release2 := make(chan struct{})
gh.onExecute = func() {
started <- struct{}{}
<-release2
}
svc.refreshStaleWorkspaceWatches("ws1", staleTasks)
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatalf("post-completion refresh never started — inflight key leaked")
}
close(release2)
}
// TestServiceStop_DrainsRefreshGoroutine verifies that Service.Stop() blocks
// until an in-flight refreshStaleWorkspaceWatches goroutine completes and
// cancels its syncCtx so it stops doing work. Without this, process
// shutdown could race a goroutine that is mid-DB-write or mid-gh-spawn.
//
// Runs inside synctest so "Stop is parked on bgWG.Wait" is a structural
// guarantee (synctest.Wait returns once every bubble goroutine is durably
// blocked) rather than a wall-clock window that could yield false negatives
// on a slow CI runner where Stop hasn't entered yet.
func TestServiceStop_DrainsRefreshGoroutine(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
w := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 0, Branch: "br-1"}
if err := store.CreatePRWatch(ctx, w); err != nil {
t.Fatalf("create watch: %v", err)
}
gh.branchResponses = []string{`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`}
started := make(chan struct{}, 1)
release := make(chan struct{})
gh.onExecute = func() {
select {
case started <- struct{}{}:
default:
}
<-release
}
svc.refreshStaleWorkspaceWatches("ws1", map[string]struct{}{"t1": {}})
// Wait until the refresh goroutine has parked on <-release.
synctest.Wait()
select {
case <-started:
default:
t.Fatalf("refresh goroutine never entered ExecuteGraphQL")
}
stopped := make(chan struct{})
go func() {
svc.Stop()
close(stopped)
}()
// Stop must park on bgWG.Wait() — the refresh goroutine still
// holds the only outstanding WaitGroup counter.
synctest.Wait()
select {
case <-stopped:
t.Fatalf("Stop() returned while refresh goroutine was still blocked — drain skipped")
default:
}
close(release)
synctest.Wait()
select {
case <-stopped:
default:
t.Fatalf("Stop() did not return after refresh goroutine released")
}
// Second Stop is a no-op.
svc.Stop()
})
}
func TestWaitForRateLimit_SkipsWhenHealthy(t *testing.T) {
poller, _, _, _ := setupPollerTest(t)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if !poller.waitForRateLimit(ctx, ResourceGraphQL, "test") {
t.Errorf("expected true when no exhaustion recorded")
}
}
func TestSearchBucketExhausted(t *testing.T) {
poller, svc, _, _ := setupPollerTest(t)
if poller.searchBucketExhausted("test") {
t.Errorf("expected false when no exhaustion recorded")
}
// Mark the search bucket exhausted with a future reset.
svc.RateTracker().Record(RateSnapshot{
Resource: ResourceSearch,
Limit: 30,
Remaining: 0,
ResetAt: time.Now().Add(5 * time.Minute),
UpdatedAt: time.Now(),
})
if !poller.searchBucketExhausted("test") {
t.Errorf("expected true when search bucket exhausted")
}
// Other buckets exhausting must not trip the search guard.
poller2, svc2, _, _ := setupPollerTest(t)
svc2.RateTracker().Record(RateSnapshot{
Resource: ResourceCore,
Remaining: 0,
ResetAt: time.Now().Add(5 * time.Minute),
UpdatedAt: time.Now(),
})
if poller2.searchBucketExhausted("test") {
t.Errorf("expected false when only core (not search) is exhausted")
}
}
// Regression: when the search bucket trips mid-cycle, checkReviewWatches must
// stop iterating so the remaining watches don't issue doomed search requests
// that deepen the secondary-limit penalty.
func TestCheckReviewWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) {
poller, svc, _, store := setupPollerTest(t)
ctx := context.Background()
// Pre-exhaust the search bucket so the very first iteration short-circuits.
svc.RateTracker().Record(RateSnapshot{
Resource: ResourceSearch,
Remaining: 0,
ResetAt: time.Now().Add(5 * time.Minute),
UpdatedAt: time.Now(),
})
for _, id := range []string{"rw1", "rw2", "rw3"} {
if err := store.CreateReviewWatch(ctx, &ReviewWatch{
ID: id,
WorkspaceID: "ws1",
Enabled: true,
}); err != nil {
t.Fatalf("seed review watch %s: %v", id, err)
}
}
// Should return cleanly without panic / without errors despite N watches
// being enabled.
poller.checkReviewWatches(ctx)
}
func TestCheckIssueWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) {
poller, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.RateTracker().Record(RateSnapshot{
Resource: ResourceSearch,
Remaining: 0,
ResetAt: time.Now().Add(5 * time.Minute),
UpdatedAt: time.Now(),
})
for _, id := range []string{"iw1", "iw2"} {
if err := store.CreateIssueWatch(ctx, &IssueWatch{
ID: id,
WorkspaceID: "ws1",
Enabled: true,
}); err != nil {
t.Fatalf("seed issue watch %s: %v", id, err)
}
}
poller.checkIssueWatches(ctx)
}
func TestWaitForRateLimit_ReturnsFalseOnContextCancel(t *testing.T) {
poller, svc, _, _ := setupPollerTest(t)
// Force exhaustion with a far-future reset.
svc.RateTracker().Record(RateSnapshot{
Resource: ResourceGraphQL,
Limit: 5000,
Remaining: 0,
ResetAt: time.Now().Add(time.Hour),
UpdatedAt: time.Now(),
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately so the timer select returns ctx.Done first
if poller.waitForRateLimit(ctx, ResourceGraphQL, "test") {
t.Errorf("expected false when context cancelled during sleep")
}
}