9178780ba2
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
568 lines
22 KiB
Go
568 lines
22 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
)
|
|
|
|
// seedUnwatchedTaskPR inserts a linked TaskPR row that no watch points at,
|
|
// last synced long enough ago to be outside PRSyncFreshnessWindow.
|
|
func seedUnwatchedTaskPR(t *testing.T, store *Store, taskID string, number int, branch string) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
staleSync := now.Add(-1 * time.Hour)
|
|
if err := store.CreateTaskPR(context.Background(), &TaskPR{
|
|
TaskID: taskID,
|
|
WorkspaceID: testWorkspaceID,
|
|
RepositoryID: "repo-1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: number,
|
|
PRURL: "https://github.com/owner/repo/pull/1293",
|
|
PRTitle: "Left behind",
|
|
HeadBranch: branch,
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
ChecksState: "success",
|
|
CreatedAt: now.Add(-3 * time.Hour),
|
|
LastSyncedAt: &staleSync,
|
|
}); err != nil {
|
|
t.Fatalf("seed unwatched task PR: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRSyncAll_RefreshesPRLeftBehindByBranchHandover is the
|
|
// regression for the stale multi-PR status bug: a task's PR watch is unique
|
|
// per (session, repository, branch), so when the session moves to a second
|
|
// branch the watch is re-pointed and the first branch's PR loses its only
|
|
// sync handle. It then keeps `state=open` forever — the UI renders an
|
|
// already-merged PR as green, mergeable, with a live merge button.
|
|
func TestTriggerPRSyncAll_RefreshesPRLeftBehindByBranchHandover(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
|
|
// PR #1293 was linked from the first branch and is stuck at "open".
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
// The task's only watch has since moved to the second branch's PR.
|
|
if _, err := svc.CreatePRWatchForWorkspace(
|
|
ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second",
|
|
); err != nil {
|
|
t.Fatalf("CreatePRWatch: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
mergedAt := now.Add(-10 * time.Minute)
|
|
mockClient.AddPR(&PR{
|
|
Number: 1293, Title: "Left behind", State: "merged",
|
|
HTMLURL: "https://github.com/owner/repo/pull/1293",
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: mergedAt, MergedAt: &mergedAt,
|
|
})
|
|
mockClient.AddPR(&PR{
|
|
Number: 1299, Title: "Current", State: "open",
|
|
HTMLURL: "https://github.com/owner/repo/pull/1299",
|
|
HeadBranch: "feature/second", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: now.Add(-1 * time.Hour), UpdatedAt: now,
|
|
})
|
|
|
|
prs, err := svc.TriggerPRSyncAll(ctx, "task-1")
|
|
if err != nil {
|
|
t.Fatalf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
if len(prs) != 2 {
|
|
t.Fatalf("expected both PR rows returned, got %d", len(prs))
|
|
}
|
|
|
|
left, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v", err)
|
|
}
|
|
if left == nil {
|
|
t.Fatal("expected the left-behind PR row to still exist")
|
|
}
|
|
if left.State != prStateMerged {
|
|
t.Errorf("left-behind PR #1293 state = %q, want %q", left.State, prStateMerged)
|
|
}
|
|
if left.MergedAt == nil {
|
|
t.Error("left-behind PR #1293 must record merged_at once reconciled")
|
|
}
|
|
|
|
// The returned slice is what the WS handler hands the frontend, so the
|
|
// merged state has to be visible there too — not only in the DB.
|
|
for _, tp := range prs {
|
|
if tp.PRNumber == 1293 && tp.State != prStateMerged {
|
|
t.Errorf("returned PR #1293 state = %q, want %q", tp.State, prStateMerged)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRSyncAll_RefreshesTaskPRWithNoWatchAtAll covers rows that never
|
|
// had a watch — a task created from a PR URL. Before the fix the no-watch
|
|
// branch returned the stored rows verbatim, so those PRs never synced once.
|
|
func TestTriggerPRSyncAll_RefreshesTaskPRWithNoWatchAtAll(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
now := time.Now().UTC()
|
|
closedAt := now.Add(-5 * time.Minute)
|
|
mockClient.AddPR(&PR{
|
|
Number: 1293, Title: "Left behind", State: "closed",
|
|
HTMLURL: "https://github.com/owner/repo/pull/1293",
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: closedAt, ClosedAt: &closedAt,
|
|
})
|
|
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "task-1"); err != nil {
|
|
t.Fatalf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v", err)
|
|
}
|
|
if got == nil || got.State != prStateClosed {
|
|
t.Fatalf("expected watch-less PR row to reach %q, got %+v", prStateClosed, got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshStaleWorkspaceWatches_HealsUnwatchedRow covers the surface the
|
|
// kanban board and the topbar aggregate read from: the workspace-wide
|
|
// background refresh (driven by ListWorkspaceTaskPRs) fans in watches only,
|
|
// so a PR left behind by a branch handover stayed stale on every workspace
|
|
// load. Also the only test that drives the batched GraphQL branch of the
|
|
// unwatched sync.
|
|
func TestRefreshStaleWorkspaceWatches_HealsUnwatchedRow(t *testing.T) {
|
|
_, svc, gh, store := setupBatchedPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
// The unwatched group is reconciled before the watch batch, so the canned
|
|
// responses are consumed in that order.
|
|
gh.prResponses = []string{
|
|
batchedMergedPRResponse("feature/first", "2026-01-02T00:00:00Z"),
|
|
batchedMergedPRResponse("feature/second", ""),
|
|
}
|
|
if _, err := svc.CreatePRWatchForWorkspace(
|
|
ctx, testWorkspaceID, "session-1", "task-1", "repo-1", "owner", "repo", 1299, "feature/second",
|
|
); err != nil {
|
|
t.Fatalf("CreatePRWatch: %v", err)
|
|
}
|
|
|
|
// Subscribe before triggering: the published event is the observable side
|
|
// effect of the row being written, so it's a deterministic join.
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
|
|
// ListWorkspaceTaskPRs derives this stale set from last_synced_at and then
|
|
// hands it to the background goroutine; the join it uses needs the real
|
|
// tasks schema, so drive the goroutine directly.
|
|
svc.refreshStaleWorkspaceWatches(testWorkspaceID, map[string]struct{}{"task-1": {}})
|
|
unwatchedUpdate := awaitTaskPRUpdated(t, updated)
|
|
if unwatchedUpdate.PRNumber != 1293 {
|
|
t.Fatalf("first background update PR = %d, want unwatched PR 1293", unwatchedUpdate.PRNumber)
|
|
}
|
|
// The same goroutine updates the watched PR after the unwatched row. Wait
|
|
// for that write too: canceling it via Stop can make sqlite discard the
|
|
// sole :memory: connection and leave the final assertion without a schema.
|
|
watchedUpdate := awaitTaskPRUpdated(t, updated)
|
|
if watchedUpdate.PRNumber != 1299 {
|
|
t.Fatalf("second background update PR = %d, want watched PR 1299", watchedUpdate.PRNumber)
|
|
}
|
|
// Drain the goroutine before the in-memory DB closes. Both observable writes
|
|
// have completed, so Stop has no database operation left to cancel.
|
|
svc.Stop()
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v", err)
|
|
}
|
|
if got == nil || got.State != prStateMerged {
|
|
t.Fatalf("expected background refresh to reach %q for the unwatched PR, got %+v", prStateMerged, got)
|
|
}
|
|
}
|
|
|
|
// TestUnwatchedReconcile_FallsBackPerPRWhenBatchFails covers the branch a
|
|
// GraphQL-capable client takes when the batch query itself fails (auth blip,
|
|
// network error): graphQLExecutorFor succeeds, runBatchedPRQuery does not, and
|
|
// the per-PR GetPR path has to carry the reconcile.
|
|
func TestUnwatchedReconcile_FallsBackPerPRWhenBatchFails(t *testing.T) {
|
|
_, svc, gh, store := setupBatchedPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
// GraphQL-capable client whose batch query fails outright.
|
|
gh.prErr = errors.New("graphql unavailable")
|
|
now := time.Now().UTC()
|
|
mergedAt := now.Add(-10 * time.Minute)
|
|
gh.AddPR(&PR{
|
|
Number: 1293, Title: "Left behind", State: prStateMerged,
|
|
HTMLURL: "https://github.com/owner/repo/pull/1293",
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: mergedAt, MergedAt: &mergedAt,
|
|
})
|
|
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "task-1"); err != nil {
|
|
t.Fatalf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
if len(gh.prQueries) == 0 {
|
|
t.Fatal("expected the batched query to be attempted before the fallback")
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: err=%v row=%v", err, got)
|
|
}
|
|
if got.State != prStateMerged || got.MergedAt == nil {
|
|
t.Fatalf("per-PR fallback did not reconcile: state=%q merged_at=%v", got.State, got.MergedAt)
|
|
}
|
|
}
|
|
|
|
// TestUnwatchedReconcile_CoalescesConcurrentFetches locks the singleflight on
|
|
// the unwatched batched fetch. The on-demand sync, the workspace background
|
|
// refresh, and a second browser tab can all want the same ref set at once;
|
|
// without coalescing each issues its own GraphQL call against the same gh
|
|
// throttle.
|
|
func TestUnwatchedReconcile_CoalescesConcurrentFetches(t *testing.T) {
|
|
_, svc, gh, store := setupBatchedPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
// Two canned responses so a second (unwanted) call fails the count
|
|
// assertion rather than starving on missing data.
|
|
gh.prResponses = []string{
|
|
batchedMergedPRResponse("feature/first", "2026-01-02T00:00:00Z"),
|
|
batchedMergedPRResponse("feature/first", "2026-01-02T00:00:00Z"),
|
|
}
|
|
started := make(chan struct{}, 4)
|
|
release := make(chan struct{})
|
|
gh.onExecute = func() {
|
|
started <- struct{}{}
|
|
<-release
|
|
}
|
|
|
|
done := make(chan struct{}, 2)
|
|
go func() { _, _ = svc.TriggerPRSyncAll(ctx, "task-1"); done <- struct{}{} }()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("first reconcile never entered ExecuteGraphQL")
|
|
}
|
|
|
|
// Second caller must join the in-flight fetch rather than start its own.
|
|
go func() { _, _ = svc.TriggerPRSyncAll(ctx, "task-1"); done <- struct{}{} }()
|
|
select {
|
|
case <-started:
|
|
t.Fatal("second reconcile issued its own GraphQL call — singleflight broken")
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Bounded negative assertion, per apps/backend/AGENTS.md.
|
|
}
|
|
|
|
close(release)
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("reconcile goroutines did not finish")
|
|
}
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil || got == nil || got.State != prStateMerged {
|
|
t.Fatalf("expected the shared flight to reconcile the row: err=%v row=%+v", err, got)
|
|
}
|
|
}
|
|
|
|
// batchedMergedPRResponse builds a single-alias batched PR query response.
|
|
// A non-empty mergedAt makes convertBatchedPRResult classify it as merged.
|
|
func batchedMergedPRResponse(headBranch, mergedAt string) string {
|
|
return `{"data":{"repo0":{"pr0":{
|
|
"state": "MERGED", "title": "PR", "url": "https://x/1",
|
|
"isDraft": false, "mergeable": "UNKNOWN", "mergeStateStatus": "UNKNOWN",
|
|
"headRefName": "` + headBranch + `", "baseRefName": "main", "headRefOid": "abc",
|
|
"author": {"login": "alice"},
|
|
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z",
|
|
"mergedAt": "` + mergedAt + `",
|
|
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
|
|
"commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}
|
|
}}}}`
|
|
}
|
|
|
|
// subscribeTaskPRUpdated returns a channel fed by github.task_pr.updated
|
|
// events. Channel synchronization rather than a polling sleep: the background
|
|
// refresh does no subprocess work, so its write is observable through the
|
|
// event it publishes (see apps/backend/AGENTS.md, "Joining production
|
|
// goroutines in tests").
|
|
func subscribeTaskPRUpdated(t *testing.T, svc *Service) <-chan *TaskPR {
|
|
t.Helper()
|
|
seen := make(chan *TaskPR, 4)
|
|
sub, err := svc.eventBus.Subscribe(events.GitHubTaskPRUpdated, func(_ context.Context, e *bus.Event) error {
|
|
tp, _ := e.Data.(*TaskPR)
|
|
select {
|
|
case seen <- tp:
|
|
default: // buffered channel full: the join only needs the first event
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("subscribe to %s: %v", events.GitHubTaskPRUpdated, err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
return seen
|
|
}
|
|
|
|
// awaitTaskPRUpdated blocks until the reconcile publishes, failing fast rather
|
|
// than letting a missing event stall the package's test timeout.
|
|
func awaitTaskPRUpdated(t *testing.T, updated <-chan *TaskPR) *TaskPR {
|
|
t.Helper()
|
|
select {
|
|
case tp := <-updated:
|
|
return tp
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("no github.task_pr.updated event published — reconcile never wrote the row")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRSyncAll_UnwatchedReconcilePreservesAggregates locks the scope
|
|
// of the unwatched path: it converges lifecycle state only. Check and review
|
|
// aggregates belong to the watch-driven sync, which fetches the reviews and
|
|
// check runs needed to compute them — reconciling them from here would persist
|
|
// a less-informed answer, because the per-PR REST status marks its counts
|
|
// populated even when it has nothing to count. E2E caught this as six PR
|
|
// popover specs losing their seeded 27/22 check counts to 0/0.
|
|
func TestTriggerPRSyncAll_UnwatchedReconcilePreservesAggregates(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
|
|
now := time.Now().UTC()
|
|
staleSync := now.Add(-1 * time.Hour)
|
|
required := 2
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "task-1", WorkspaceID: testWorkspaceID, RepositoryID: "repo-1",
|
|
Owner: "owner", Repo: "repo", PRNumber: 1293,
|
|
PRURL: "https://github.com/owner/repo/pull/1293", PRTitle: "Left behind",
|
|
HeadBranch: "feature/first", BaseBranch: "main", State: "open",
|
|
// Aggregates a richer sync already computed.
|
|
ChecksState: "failure", ChecksTotal: 27, ChecksPassing: 22,
|
|
ReviewState: "approved", ReviewCount: 1, PendingReviewCount: 0,
|
|
RequiredReviews: &required, UnresolvedReviewThreads: 3,
|
|
MergeableState: "blocked",
|
|
CreatedAt: now.Add(-3 * time.Hour), LastSyncedAt: &staleSync,
|
|
}); err != nil {
|
|
t.Fatalf("seed row: %v", err)
|
|
}
|
|
|
|
// The provider knows the PR merged but carries no reviews and no check
|
|
// runs — exactly the shape that zeroed the aggregates before this fix.
|
|
mergedAt := now.Add(-10 * time.Minute)
|
|
mockClient.AddPR(&PR{
|
|
Number: 1293, Title: "Left behind", State: prStateMerged,
|
|
HTMLURL: "https://github.com/owner/repo/pull/1293",
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: now.Add(-3 * time.Hour), UpdatedAt: mergedAt, MergedAt: &mergedAt,
|
|
})
|
|
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "task-1"); err != nil {
|
|
t.Fatalf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: err=%v row=%v", err, got)
|
|
}
|
|
// Lifecycle converged...
|
|
if got.State != prStateMerged || got.MergedAt == nil {
|
|
t.Errorf("state=%q merged_at=%v, want merged with a timestamp", got.State, got.MergedAt)
|
|
}
|
|
// ...and nothing else was touched.
|
|
if got.ChecksTotal != 27 || got.ChecksPassing != 22 {
|
|
t.Errorf("checks %d/%d, want 27/22 preserved", got.ChecksPassing, got.ChecksTotal)
|
|
}
|
|
if got.ChecksState != "failure" {
|
|
t.Errorf("checks_state = %q, want %q preserved", got.ChecksState, "failure")
|
|
}
|
|
if got.ReviewState != "approved" || got.ReviewCount != 1 {
|
|
t.Errorf("review_state=%q count=%d, want approved/1 preserved", got.ReviewState, got.ReviewCount)
|
|
}
|
|
if got.UnresolvedReviewThreads != 3 {
|
|
t.Errorf("unresolved_review_threads = %d, want 3 preserved", got.UnresolvedReviewThreads)
|
|
}
|
|
if got.RequiredReviews == nil || *got.RequiredReviews != 2 {
|
|
t.Errorf("required_reviews = %v, want 2 preserved", got.RequiredReviews)
|
|
}
|
|
if got.MergeableState != "blocked" {
|
|
t.Errorf("mergeable_state = %q, want %q preserved", got.MergeableState, "blocked")
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRSyncAll_UnwatchedReconcileWritesOutcomeAttribution is the
|
|
// regression for AC-36: a PR that merges with no active watch is reconciled
|
|
// only through syncUnwatchedTaskPRGroup, never through SyncTaskPR. Before
|
|
// this fix, reconcileTaskPRLifecycle discarded the already-fetched draft
|
|
// status and merger identity, so the row went terminal with
|
|
// merged_by_login/is_draft still NULL — merged_at >= activation with a NULL
|
|
// merged_by_login is exactly the writer-fault state AC-36 forbids.
|
|
func TestTriggerPRSyncAll_UnwatchedReconcileWritesOutcomeAttribution(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedUnwatchedTaskPR(t, store, "task-1", 1293, "feature/first")
|
|
|
|
mergedAt := time.Now().UTC().Add(-10 * time.Minute)
|
|
mockClient.AddPR(&PR{
|
|
Number: 1293, Title: "Left behind", State: prStateMerged,
|
|
HTMLURL: "https://github.com/owner/repo/pull/1293",
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
CreatedAt: mergedAt.Add(-2 * time.Hour), UpdatedAt: mergedAt, MergedAt: &mergedAt,
|
|
Draft: false, IsDraftObserved: true,
|
|
ChangedFiles: 4, ChangedFilesObserved: true,
|
|
MergedByLogin: "carlosflorencio",
|
|
})
|
|
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "task-1"); err != nil {
|
|
t.Fatalf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 1293)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: err=%v row=%v", err, got)
|
|
}
|
|
if got.State != prStateMerged || got.MergedAt == nil {
|
|
t.Fatalf("state=%q merged_at=%v, want merged with a timestamp", got.State, got.MergedAt)
|
|
}
|
|
if got.MergedByLogin == nil || *got.MergedByLogin != "carlosflorencio" {
|
|
t.Errorf("merged_by_login = %v, want %q (AC-36 requires non-NULL once merged_at is set)",
|
|
got.MergedByLogin, "carlosflorencio")
|
|
}
|
|
if got.IsDraft == nil || *got.IsDraft != false {
|
|
t.Errorf("is_draft = %v, want false observed, not NULL", got.IsDraft)
|
|
}
|
|
if got.ChangedFiles == nil || *got.ChangedFiles != 4 {
|
|
t.Errorf("changed_files = %v, want 4 observed, not NULL", got.ChangedFiles)
|
|
}
|
|
}
|
|
|
|
func TestUnwatchedTaskPRs_Selection(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
stale := now.Add(-1 * time.Hour)
|
|
fresh := now.Add(-1 * time.Second)
|
|
detached := now.Add(-2 * time.Hour)
|
|
|
|
row := func(number int, state string, syncedAt *time.Time) *TaskPR {
|
|
return &TaskPR{
|
|
TaskID: "task-1", Owner: "owner", Repo: "repo",
|
|
PRNumber: number, State: state, LastSyncedAt: syncedAt,
|
|
}
|
|
}
|
|
detachedRow := row(7, "open", &stale)
|
|
detachedRow.DetachedAt = &detached
|
|
|
|
rows := []*TaskPR{
|
|
row(1, "open", &stale), // stale + unwatched -> selected
|
|
row(2, "open", &stale), // watched -> skipped
|
|
row(3, prStateMerged, &stale), // terminal -> skipped
|
|
row(4, prStateClosed, &stale), // terminal -> skipped
|
|
row(5, "open", &fresh), // inside freshness window -> skipped
|
|
row(6, "open", nil), // never synced -> selected
|
|
detachedRow, // detached -> skipped
|
|
}
|
|
watches := []*PRWatch{
|
|
{Owner: "owner", Repo: "repo", PRNumber: 2},
|
|
{Owner: "owner", Repo: "repo", PRNumber: 0}, // searching watch covers nothing
|
|
nil,
|
|
}
|
|
|
|
got := unwatchedTaskPRs(rows, watches, now)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 selected rows, got %d (%+v)", len(got), got)
|
|
}
|
|
if got[0].PRNumber != 1 || got[1].PRNumber != 6 {
|
|
t.Errorf("selected PRs = %d, %d; want 1, 6", got[0].PRNumber, got[1].PRNumber)
|
|
}
|
|
}
|
|
|
|
// TestReconcileTaskPRLifecycle_PublishesReReadValueNotStaleInMemoryOne is the
|
|
// AC-18 regression on the unwatched-sync path, mirroring
|
|
// TestPersistAndPublishTaskPRSync_PublishesReReadValueNotStaleInMemoryOne in
|
|
// service_pr_outcome_sync_test.go for the codex [P2] race on the other
|
|
// writer. reconcileTaskPRLifecycle used to publish its own in-memory tp
|
|
// directly after UpdateTaskPR; because UpdateTaskPR persists
|
|
// AutoMergeObservedAt through COALESCE(auto_merge_observed_at, ?), a
|
|
// concurrent writer that lands first leaves the stored column holding an
|
|
// earlier timestamp than this call's own resolved value, and the old code
|
|
// would broadcast the wrong one. It now delegates the write+publish to
|
|
// persistAndPublishTaskPRSync, which re-reads before publishing.
|
|
func TestReconcileTaskPRLifecycle_PublishesReReadValueNotStaleInMemoryOne(t *testing.T) {
|
|
_, svc, _, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedUnwatchedTaskPR(t, store, "task-unwatched-p2-race", 1293, "feature/first")
|
|
|
|
tp, err := store.GetTaskPRByRepoAndNumber(ctx, "task-unwatched-p2-race", "repo-1", 1293)
|
|
if err != nil || tp == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: err=%v row=%v", err, tp)
|
|
}
|
|
|
|
// Simulate a concurrent sync that already landed its own write: the DB
|
|
// now holds persistedAt, set while this call's own tp (below) still
|
|
// reflects the pre-race value it read moments earlier.
|
|
concurrentWriter := *tp
|
|
persistedAt := time.Now().UTC()
|
|
concurrentWriter.AutoMergeObservedAt = &persistedAt
|
|
if err := store.UpdateTaskPR(ctx, &concurrentWriter); err != nil {
|
|
t.Fatalf("simulate concurrent writer: %v", err)
|
|
}
|
|
|
|
// This call's stale in-memory view, computed before the concurrent
|
|
// writer's timestamp above landed.
|
|
staleObservedAt := persistedAt.Add(-time.Minute)
|
|
tp.AutoMergeObservedAt = &staleObservedAt
|
|
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
|
|
mergedAt := time.Now().UTC()
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1293, State: prStateMerged, MergedAt: &mergedAt,
|
|
HeadBranch: "feature/first", BaseBranch: "main",
|
|
RepoOwner: "owner", RepoName: "repo",
|
|
},
|
|
}
|
|
|
|
if err := svc.reconcileTaskPRLifecycle(ctx, tp, status); err != nil {
|
|
t.Fatalf("reconcileTaskPRLifecycle: %v", err)
|
|
}
|
|
|
|
published := awaitTaskPRUpdated(t, updated)
|
|
if published.AutoMergeObservedAt == nil || !published.AutoMergeObservedAt.Equal(persistedAt) {
|
|
t.Fatalf("published AutoMergeObservedAt = %v, want the re-read persisted value %v, not the stale in-memory %v",
|
|
published.AutoMergeObservedAt, persistedAt, staleObservedAt)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByID(ctx, tp.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskPRByID: %v", err)
|
|
}
|
|
if got.AutoMergeObservedAt == nil || !got.AutoMergeObservedAt.Equal(persistedAt) {
|
|
t.Fatalf("persisted AutoMergeObservedAt = %v, want the concurrent writer's %v preserved by COALESCE",
|
|
got.AutoMergeObservedAt, persistedAt)
|
|
}
|
|
}
|