46021e7d23
PR state was written by two flows and the second one could not persist: the feedback fetch returned live PR data without saving it, and the browser patched its own store copy instead. Opening a PR's detail panel turned that tab purple while the kanban card and boot payload still read state=open, and a reload snapped it back. The backend is now the only writer — the feedback read reconciles through SyncTaskPR and publishes github.task_pr.updated, so every surface converges and the state survives a reload. SyncTaskPR also refuses to move a row out of merged, since GitHub cannot un-merge a PR.
360 lines
14 KiB
Go
360 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
feedbackSyncOwner = "owner"
|
|
feedbackSyncRepo = "repo"
|
|
feedbackSyncHead = "sha-feedback"
|
|
)
|
|
|
|
// seedFeedbackTaskPR inserts a linked TaskPR row for the feedback-sync tests,
|
|
// applying `mutate` so each test can dirty the fields it cares about.
|
|
func seedFeedbackTaskPR(t *testing.T, store *Store, taskID string, number int, mutate func(*TaskPR)) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
staleSync := now.Add(-1 * time.Hour)
|
|
tp := &TaskPR{
|
|
TaskID: taskID,
|
|
WorkspaceID: testWorkspaceID,
|
|
RepositoryID: "repo-1",
|
|
Owner: feedbackSyncOwner,
|
|
Repo: feedbackSyncRepo,
|
|
PRNumber: number,
|
|
PRURL: "https://github.com/owner/repo/pull/2408",
|
|
PRTitle: "Collapse the dual writers",
|
|
HeadBranch: "feature/collapse",
|
|
BaseBranch: "main",
|
|
State: prStateOpen,
|
|
CreatedAt: now.Add(-3 * time.Hour),
|
|
LastSyncedAt: &staleSync,
|
|
}
|
|
if mutate != nil {
|
|
mutate(tp)
|
|
}
|
|
if err := store.CreateTaskPR(context.Background(), tp); err != nil {
|
|
t.Fatalf("seed feedback task PR: %v", err)
|
|
}
|
|
}
|
|
|
|
// addMergedFeedbackPR registers a merged PR the workspace's credentials can
|
|
// reach, so GetPRFeedbackForWorkspace resolves a client and returns live data.
|
|
func addMergedFeedbackPR(mockClient *MockClient, number int, mergedAt time.Time) {
|
|
mockClient.AddRepos("test-user", []GitHubRepo{{
|
|
Owner: feedbackSyncOwner, Name: feedbackSyncRepo,
|
|
FullName: feedbackSyncOwner + "/" + feedbackSyncRepo,
|
|
}})
|
|
mockClient.AddPR(&PR{
|
|
Number: number, Title: "Collapse the dual writers", State: prStateMerged,
|
|
HTMLURL: "https://github.com/owner/repo/pull/2408",
|
|
HeadBranch: "feature/collapse", BaseBranch: "main", HeadSHA: feedbackSyncHead,
|
|
RepoOwner: feedbackSyncOwner, RepoName: feedbackSyncRepo,
|
|
CreatedAt: mergedAt.Add(-3 * time.Hour), UpdatedAt: mergedAt,
|
|
MergedAt: &mergedAt, ClosedAt: &mergedAt,
|
|
})
|
|
}
|
|
|
|
// The bug this collapses: GetPRFeedbackForWorkspace already holds an
|
|
// authoritative live *PR server-side, but used to return it without persisting.
|
|
// The browser then patched only its in-memory Zustand copy, so opening a PR's
|
|
// detail panel turned that tab purple while github_task_prs still said
|
|
// state=open — every other surface disagreed, and a reload snapped it back.
|
|
// Confirmed on a live instance for kdlbs/kandev#2408.
|
|
//
|
|
// This covers the stale-row direction (stored open, live merged). The inverse
|
|
// — stored merged, live read stale at open — is covered one layer down by
|
|
// TestSyncTaskPR_DoesNotRegressAMergedRow, deliberately: persistPRFeedbackState
|
|
// has no state logic of its own, it hands every row to the real SyncTaskPR, so
|
|
// asserting the guard at its own seam is where a break would actually surface.
|
|
func TestGetPRFeedbackForWorkspace_PersistsMergedStateOverStaleRow(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, nil)
|
|
|
|
mergedAt := time.Now().UTC().Add(-10 * time.Minute)
|
|
addMergedFeedbackPR(mockClient, 2408, mergedAt)
|
|
|
|
// The published event is what converges every other surface, so subscribe
|
|
// before the fetch and use it as the join.
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
|
|
if _, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
); err != nil {
|
|
t.Fatalf("GetPRFeedbackForWorkspace: %v", err)
|
|
}
|
|
|
|
event := awaitTaskPRUpdated(t, updated)
|
|
if event == nil || event.State != prStateMerged {
|
|
t.Fatalf("published event state = %+v, want %q", event, prStateMerged)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected the task PR row to still exist")
|
|
}
|
|
if got.State != prStateMerged {
|
|
t.Errorf("persisted state = %q, want %q", got.State, prStateMerged)
|
|
}
|
|
if got.MergedAt == nil {
|
|
t.Error("persisted merged_at must be set once the feedback fetch observes the merge")
|
|
}
|
|
}
|
|
|
|
// A feedback fetch computes checks and review counts (it makes the same
|
|
// GetPR + ListPRReviews + ListCheckRuns calls the REST status sync does) but
|
|
// never fetches review threads. SyncTaskPR's Populated flags must therefore
|
|
// carry unresolved_review_threads through untouched, while the two counts it
|
|
// did compute come from the real lists rather than being reset to zero.
|
|
func TestGetPRFeedbackForWorkspace_PartialSyncKeepsUncomputedAggregates(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, func(tp *TaskPR) {
|
|
tp.ReviewCount = 3
|
|
tp.ChecksTotal = 27
|
|
tp.ChecksPassing = 22
|
|
// Only the GraphQL path can compute this one.
|
|
tp.UnresolvedReviewThreads = 4
|
|
})
|
|
|
|
mergedAt := time.Now().UTC().Add(-10 * time.Minute)
|
|
addMergedFeedbackPR(mockClient, 2408, mergedAt)
|
|
mockClient.AddReviews(feedbackSyncOwner, feedbackSyncRepo, 2408, []PRReview{
|
|
{ID: 1, Author: "octocat", State: reviewStateApproved, CreatedAt: mergedAt},
|
|
})
|
|
mockClient.AddCheckRuns(feedbackSyncOwner, feedbackSyncRepo, feedbackSyncHead, []CheckRun{
|
|
{Name: "build", Status: checkStatusCompleted, Conclusion: checkConclusionSuccess},
|
|
{Name: "lint", Status: checkStatusCompleted, Conclusion: checkConclusionSuccess},
|
|
})
|
|
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
if _, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
); err != nil {
|
|
t.Fatalf("GetPRFeedbackForWorkspace: %v", err)
|
|
}
|
|
awaitTaskPRUpdated(t, updated)
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, got)
|
|
}
|
|
if got.UnresolvedReviewThreads != 4 {
|
|
t.Errorf("unresolved_review_threads = %d, want the stored 4 preserved — "+
|
|
"the feedback path never fetches review threads", got.UnresolvedReviewThreads)
|
|
}
|
|
// Counted from the real lists, so neither is allowed to land on zero.
|
|
if got.ReviewCount != 1 {
|
|
t.Errorf("review_count = %d, want 1 approving reviewer", got.ReviewCount)
|
|
}
|
|
if got.ChecksTotal != 2 || got.ChecksPassing != 2 {
|
|
t.Errorf("checks = %d/%d, want 2/2", got.ChecksPassing, got.ChecksTotal)
|
|
}
|
|
}
|
|
|
|
// The feedback path is TTL-cached and singleflight-coalesced; the persist must
|
|
// ride the upstream fetch, not the read. Otherwise every render of an open
|
|
// detail panel writes the row and republishes an event, and the WS handler
|
|
// applying that event re-renders the panel — a write amplifier on a read path.
|
|
func TestGetPRFeedbackForWorkspace_CachedResponseDoesNotRewriteRow(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, nil)
|
|
|
|
mergedAt := time.Now().UTC().Add(-10 * time.Minute)
|
|
addMergedFeedbackPR(mockClient, 2408, mergedAt)
|
|
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
for i := range 3 {
|
|
if _, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
); err != nil {
|
|
t.Fatalf("GetPRFeedbackForWorkspace call %d: %v", i, err)
|
|
}
|
|
}
|
|
awaitTaskPRUpdated(t, updated)
|
|
|
|
first, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil || first == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, first)
|
|
}
|
|
writtenAt := first.LastSyncedAt
|
|
if writtenAt == nil {
|
|
t.Fatal("expected the first fetch to stamp last_synced_at")
|
|
}
|
|
|
|
// Cache hits must not touch the row at all.
|
|
if _, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
); err != nil {
|
|
t.Fatalf("GetPRFeedbackForWorkspace (cached): %v", err)
|
|
}
|
|
after, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil || after == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, after)
|
|
}
|
|
if !timeEqual(after.LastSyncedAt, writtenAt) {
|
|
t.Errorf("cached feedback rewrote the row: last_synced_at %v -> %v", writtenAt, after.LastSyncedAt)
|
|
}
|
|
// ...and must not republish either, or the WS handler applying the event
|
|
// re-renders the panel that triggered it. MemoryEventBus delivers to
|
|
// subscribers synchronously (see MemoryEventBus.Publish), so any event the
|
|
// call above caused is already in the channel — a non-blocking read is
|
|
// exact here, with no sleep to go flaky under load.
|
|
//
|
|
// last_synced_at above is the assertion that actually catches a persist
|
|
// moved outside the fetch closure; this one is defence in depth. A
|
|
// redundant sync writes the row but re-derives identical field values, so
|
|
// SyncTaskPR's `changed` guard already suppresses the event — this pins
|
|
// that no-WS-traffic-on-a-cache-hit property against a future change that
|
|
// relaxes that guard.
|
|
select {
|
|
case extra := <-updated:
|
|
t.Errorf("cached feedback republished github.task_pr.updated: %+v", extra)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Rows belonging to another workspace must stay untouched: the feedback read
|
|
// was authorized against one workspace's credentials only, so the persist
|
|
// cannot reach past that boundary just because a PR number matches.
|
|
func TestGetPRFeedbackForWorkspace_DoesNotWriteOtherWorkspacesRows(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
if _, err := store.db.Exec(
|
|
`INSERT INTO tasks (id, workspace_id, archived_at) VALUES (?, ?, NULL)`,
|
|
"task-other", "ws-other"); err != nil {
|
|
t.Fatalf("seed foreign task: %v", err)
|
|
}
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, nil)
|
|
seedFeedbackTaskPR(t, store, "task-other", 2408, func(tp *TaskPR) {
|
|
tp.WorkspaceID = "ws-other"
|
|
tp.RepositoryID = "repo-other"
|
|
})
|
|
|
|
mergedAt := time.Now().UTC().Add(-10 * time.Minute)
|
|
addMergedFeedbackPR(mockClient, 2408, mergedAt)
|
|
|
|
updated := subscribeTaskPRUpdated(t, svc)
|
|
if _, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
); err != nil {
|
|
t.Fatalf("GetPRFeedbackForWorkspace: %v", err)
|
|
}
|
|
awaitTaskPRUpdated(t, updated)
|
|
|
|
foreign, err := store.GetTaskPRByRepoAndNumber(ctx, "task-other", "repo-other", 2408)
|
|
if err != nil || foreign == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, foreign)
|
|
}
|
|
if foreign.State != prStateOpen {
|
|
t.Errorf("foreign-workspace row state = %q, want it left at %q", foreign.State, prStateOpen)
|
|
}
|
|
}
|
|
|
|
// A failing store must not turn a working PR detail panel into an error: this
|
|
// is a read endpoint that gained a write, so the write is best-effort.
|
|
func TestGetPRFeedbackForWorkspace_PersistFailureDoesNotFailTheRead(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, nil)
|
|
addMergedFeedbackPR(mockClient, 2408, time.Now().UTC().Add(-10*time.Minute))
|
|
|
|
// Drop the table the persist path reads to force a store error underneath it.
|
|
if _, err := store.db.Exec(`DROP TABLE tasks`); err != nil {
|
|
t.Fatalf("drop tasks table: %v", err)
|
|
}
|
|
|
|
feedback, err := svc.GetPRFeedbackForWorkspace(
|
|
ctx, testWorkspaceID, "", feedbackSyncOwner, feedbackSyncRepo, 2408,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("persist failure must not fail the feedback read: %v", err)
|
|
}
|
|
if feedback == nil || feedback.PR == nil || feedback.PR.State != prStateMerged {
|
|
t.Fatalf("expected the live feedback to still be returned, got %+v", feedback)
|
|
}
|
|
}
|
|
|
|
// SyncTaskPR is now the only writer, so the one invariant the deleted
|
|
// client-side hook was protecting has to live here: GitHub cannot un-merge a
|
|
// PR, so a stale live read reporting "open" for a merged row must not regress
|
|
// it. Only "merged" is terminal — see the reopen case below.
|
|
func TestSyncTaskPR_DoesNotRegressAMergedRow(t *testing.T) {
|
|
_, svc, _, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
mergedAt := time.Now().UTC().Add(-1 * time.Hour)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, func(tp *TaskPR) {
|
|
tp.State = prStateMerged
|
|
tp.MergedAt = &mergedAt
|
|
tp.ClosedAt = &mergedAt
|
|
})
|
|
|
|
// A stale read — an eventually-consistent REST reply, or a fetch that
|
|
// started before another writer observed the merge.
|
|
stale := &PRStatus{PR: &PR{
|
|
Number: 2408, State: prStateOpen, Title: "Collapse the dual writers",
|
|
RepoOwner: feedbackSyncOwner, RepoName: feedbackSyncRepo, BaseBranch: "main",
|
|
}}
|
|
if err := svc.SyncTaskPR(ctx, "task-1", stale); err != nil {
|
|
t.Fatalf("SyncTaskPR: %v", err)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, got)
|
|
}
|
|
if got.State != prStateMerged {
|
|
t.Errorf("state = %q, want the terminal %q preserved", got.State, prStateMerged)
|
|
}
|
|
if got.MergedAt == nil {
|
|
t.Error("merged_at must survive a stale non-merged read")
|
|
}
|
|
}
|
|
|
|
// The counterpart: closed -> open is a real GitHub transition (a PR can be
|
|
// reopened), so the guard must be merged-only. A blanket merged > closed > open
|
|
// rank — what the frontend hook applied — would pin every reopened PR closed.
|
|
func TestSyncTaskPR_AllowsReopeningAClosedRow(t *testing.T) {
|
|
_, svc, _, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
seedTask(t, store, "task-1", false)
|
|
closedAt := time.Now().UTC().Add(-1 * time.Hour)
|
|
seedFeedbackTaskPR(t, store, "task-1", 2408, func(tp *TaskPR) {
|
|
tp.State = prStateClosed
|
|
tp.ClosedAt = &closedAt
|
|
})
|
|
|
|
reopened := &PRStatus{PR: &PR{
|
|
Number: 2408, State: prStateOpen, Title: "Collapse the dual writers",
|
|
RepoOwner: feedbackSyncOwner, RepoName: feedbackSyncRepo, BaseBranch: "main",
|
|
}}
|
|
if err := svc.SyncTaskPR(ctx, "task-1", reopened); err != nil {
|
|
t.Fatalf("SyncTaskPR: %v", err)
|
|
}
|
|
|
|
got, err := store.GetTaskPRByRepoAndNumber(ctx, "task-1", "repo-1", 2408)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("GetTaskPRByRepoAndNumber: %v (row %+v)", err, got)
|
|
}
|
|
if got.State != prStateOpen {
|
|
t.Errorf("state = %q, want %q — reopening must not be treated as a regression", got.State, prStateOpen)
|
|
}
|
|
if got.ClosedAt != nil {
|
|
t.Error("closed_at must clear when the PR reopens")
|
|
}
|
|
}
|