298 lines
11 KiB
Go
298 lines
11 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type scheduleActivityProvider struct {
|
|
activities map[string]PRWatchTaskActivity
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (p *scheduleActivityProvider) LoadPRWatchTaskActivity(
|
|
_ context.Context, taskIDs []string,
|
|
) (map[string]PRWatchTaskActivity, error) {
|
|
p.calls++
|
|
if p.err != nil {
|
|
return nil, p.err
|
|
}
|
|
result := make(map[string]PRWatchTaskActivity, len(taskIDs))
|
|
for _, taskID := range taskIDs {
|
|
if activity, ok := p.activities[taskID]; ok {
|
|
result[taskID] = activity
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveSchedule(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
lastChecked := now.Add(-15 * time.Minute)
|
|
createdAt := now.Add(-48 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
watch *PRWatch
|
|
activity PRWatchTaskActivity
|
|
wantEvery time.Duration
|
|
wantDue bool
|
|
}{
|
|
{
|
|
name: "running is fast",
|
|
watch: scheduleWatch("running", 0, timePtr(lastChecked), createdAt),
|
|
activity: PRWatchTaskActivity{Running: true, LastActivityAt: now.Add(-12 * time.Hour)},
|
|
wantEvery: searchFastPollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "below two hours is fast",
|
|
watch: scheduleWatch("recent", 0, timePtr(lastChecked), createdAt),
|
|
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-2*time.Hour + time.Second)},
|
|
wantEvery: searchFastPollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "two hour boundary is fifteen minutes",
|
|
watch: scheduleWatch("two-hours", 0, timePtr(now.Add(-15*time.Minute)), createdAt),
|
|
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-2 * time.Hour)},
|
|
wantEvery: searchIdlePollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "just below twenty four hours is fifteen minutes",
|
|
watch: scheduleWatch("day-minus", 0, timePtr(now.Add(-15*time.Minute)), createdAt),
|
|
activity: PRWatchTaskTaskActivityAt(now.Add(-24*time.Hour + time.Second)),
|
|
wantEvery: searchIdlePollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "twenty four hour boundary is thirty minutes",
|
|
watch: scheduleWatch("day", 0, timePtr(now.Add(-30*time.Minute)), createdAt),
|
|
activity: PRWatchTaskTaskActivityAt(now.Add(-24 * time.Hour)),
|
|
wantEvery: searchVeryIdlePollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "slow target waits for its interval",
|
|
watch: scheduleWatch("slow", 0, timePtr(now.Add(-10*time.Minute)), createdAt),
|
|
activity: PRWatchTaskTaskActivityAt(now.Add(-24 * time.Hour)),
|
|
wantEvery: searchVeryIdlePollInterval,
|
|
wantDue: false,
|
|
},
|
|
{
|
|
name: "new watch is due",
|
|
watch: scheduleWatch("new", 0, nil, now),
|
|
activity: PRWatchTaskActivity{},
|
|
wantEvery: searchFastPollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "unknown activity stays fast",
|
|
watch: scheduleWatch("unknown", 0, timePtr(now.Add(-time.Minute)), createdAt),
|
|
activity: PRWatchTaskActivity{},
|
|
wantEvery: searchFastPollInterval,
|
|
wantDue: true,
|
|
},
|
|
{
|
|
name: "known PR stays fast",
|
|
watch: scheduleWatch("known", 42, timePtr(now.Add(-time.Minute)), createdAt),
|
|
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)},
|
|
wantEvery: defaultPRPollInterval,
|
|
wantDue: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := prWatchSearchInterval(now, tt.watch, tt.activity); got != tt.wantEvery {
|
|
t.Fatalf("interval = %s, want %s", got, tt.wantEvery)
|
|
}
|
|
if got := prWatchIsDue(now, tt.watch, tt.activity); got != tt.wantDue {
|
|
t.Fatalf("due = %t, want %t", got, tt.wantDue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchCreationKeepsNewWatchFast(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
watch := scheduleWatch("old-task-new-watch", 0, timePtr(now.Add(-2*time.Minute)), now.Add(-30*time.Minute))
|
|
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)}
|
|
|
|
if got := prWatchSearchInterval(now, watch, activity); got != searchFastPollInterval {
|
|
t.Fatalf("new watch interval = %s, want %s", got, searchFastPollInterval)
|
|
}
|
|
if !prWatchIsDue(now, watch, activity) {
|
|
t.Fatal("new watch after its first completed check is not due on the fast tier")
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleSharedTargets(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
|
|
"fast-task": {LastActivityAt: now.Add(-time.Hour)},
|
|
"slow-task": {LastActivityAt: now.Add(-24 * time.Hour)},
|
|
}}
|
|
poller := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
|
|
|
|
sharedFast := scheduleWatch("fast-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-time.Hour))
|
|
sharedFast.Owner = "owner"
|
|
sharedFast.Repo = "repo"
|
|
sharedFast.Branch = "shared"
|
|
sharedSlow := scheduleWatch("slow-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
|
|
sharedSlow.Owner = "owner"
|
|
sharedSlow.Repo = "repo"
|
|
sharedSlow.Branch = "shared"
|
|
allIdle := scheduleWatch("slow-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
|
|
allIdle.Owner = "owner"
|
|
allIdle.Repo = "repo"
|
|
allIdle.Branch = "idle"
|
|
|
|
got := poller.selectDuePRWatches(context.Background(), []*PRWatch{sharedSlow, sharedFast, allIdle})
|
|
if len(got) != 2 {
|
|
t.Fatalf("due watches = %d, want shared target only (2)", len(got))
|
|
}
|
|
if got[0] != sharedSlow || got[1] != sharedFast {
|
|
t.Fatalf("due watches = [%p, %p], want shared watches [%p, %p]", got[0], got[1], sharedSlow, sharedFast)
|
|
}
|
|
if provider.calls != 1 {
|
|
t.Fatalf("activity provider calls = %d, want 1 bulk call", provider.calls)
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleSeparatesNumberedTarget(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
|
|
"searching-task": {LastActivityAt: now.Add(-24 * time.Hour)},
|
|
}}
|
|
poller := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
|
|
|
|
numbered := scheduleWatch("numbered-task", 42, timePtr(now.Add(-time.Minute)), now.Add(-time.Hour))
|
|
numbered.Owner, numbered.Repo, numbered.Branch = "owner", "repo", "shared"
|
|
searching := scheduleWatch("searching-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
|
|
searching.Owner, searching.Repo, searching.Branch = "owner", "repo", "shared"
|
|
|
|
got := poller.selectDuePRWatches(context.Background(), []*PRWatch{searching, numbered})
|
|
if len(got) != 1 || got[0] != numbered {
|
|
t.Fatalf("due watches = %#v, want only numbered watch", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleActivityAndRestart(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
watch := scheduleWatch("task-1", 0, timePtr(now.Add(-10*time.Minute)), now.Add(-48*time.Hour))
|
|
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-24 * time.Hour)}
|
|
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{"task-1": activity}}
|
|
|
|
first := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
|
|
if got := first.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 0 {
|
|
t.Fatalf("slow watch due before interval = %d, want 0", len(got))
|
|
}
|
|
|
|
// A fresh activity observation makes the same persisted last check due on
|
|
// the next scheduler tick, even though the previous cycle used the slow tier.
|
|
provider.activities["task-1"] = PRWatchTaskActivity{LastActivityAt: now.Add(-time.Minute)}
|
|
if got := first.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
|
|
t.Fatalf("watch after fresh activity due = %d, want 1", len(got))
|
|
}
|
|
|
|
// A new poller sees the same persisted timestamp and activity projection.
|
|
provider.activities["task-1"] = activity
|
|
restarted := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now.Add(20 * time.Minute) }}
|
|
if got := restarted.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
|
|
t.Fatalf("watch after restart due = %d, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleProviderFailureFailsOpen(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
watch := scheduleWatch("task-1", 0, timePtr(now.Add(-time.Minute)), now.Add(-48*time.Hour))
|
|
poller := &Poller{
|
|
taskActivityProvider: &scheduleActivityProvider{err: errors.New("activity unavailable")},
|
|
clock: func() time.Time { return now },
|
|
}
|
|
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
|
|
t.Fatalf("watch after activity read failure due = %d, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleKeepsExternalPRDiscoverable(t *testing.T) {
|
|
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
|
|
watch := scheduleWatch("task-external", 0, timePtr(now.Add(-30*time.Minute)), now.Add(-48*time.Hour))
|
|
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)}
|
|
poller := &Poller{
|
|
taskActivityProvider: &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
|
|
watch.TaskID: activity,
|
|
}},
|
|
clock: func() time.Time { return now },
|
|
}
|
|
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
|
|
t.Fatalf("external PR first slow-tier check = %d, want 1", len(got))
|
|
}
|
|
// No local commit or task activity is added. The 30-minute tier remains a
|
|
// periodic search, so a PR opened externally remains discoverable.
|
|
poller.clock = func() time.Time { return now.Add(30 * time.Minute) }
|
|
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
|
|
t.Fatalf("external PR later slow-tier check = %d, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSearchingWatchAdaptiveScheduleExcludesArchivedAndDeletedTasks(t *testing.T) {
|
|
poller, _, _, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-schedule-active", false)
|
|
seedTask(t, store, "task-schedule-archived", true)
|
|
for _, taskID := range []string{"task-schedule-active", "task-schedule-archived"} {
|
|
if err := store.CreatePRWatch(ctx, withTestWorkspace(&PRWatch{
|
|
SessionID: "session-" + taskID, TaskID: taskID, Owner: "owner", Repo: "repo", Branch: taskID,
|
|
})); err != nil {
|
|
t.Fatalf("create watch for %s: %v", taskID, err)
|
|
}
|
|
}
|
|
|
|
active, err := store.ListActivePRWatches(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list active watches: %v", err)
|
|
}
|
|
if len(active) != 1 || active[0].TaskID != "task-schedule-active" {
|
|
t.Fatalf("active watches = %+v, want active task only", active)
|
|
}
|
|
if _, err := store.db.Exec(`DELETE FROM tasks WHERE id = ?`, "task-schedule-active"); err != nil {
|
|
t.Fatalf("delete task: %v", err)
|
|
}
|
|
active, err = store.ListActivePRWatches(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list watches after delete: %v", err)
|
|
}
|
|
if len(active) != 0 {
|
|
t.Fatalf("watches after task delete = %+v, want none", active)
|
|
}
|
|
_ = poller
|
|
}
|
|
|
|
func scheduleWatch(taskID string, prNumber int, lastChecked *time.Time, createdAt time.Time) *PRWatch {
|
|
return &PRWatch{
|
|
ID: taskID + "-watch",
|
|
TaskID: taskID,
|
|
WorkspaceID: "workspace-1",
|
|
Owner: "owner-" + taskID,
|
|
Repo: "repo-" + taskID,
|
|
Branch: "branch-" + taskID,
|
|
PRNumber: prNumber,
|
|
LastCheckedAt: lastChecked,
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
func timePtr(value time.Time) *time.Time {
|
|
return &value
|
|
}
|
|
|
|
func PRWatchTaskTaskActivityAt(at time.Time) PRWatchTaskActivity {
|
|
return PRWatchTaskActivity{LastActivityAt: at}
|
|
}
|