14c19ea538
* feat(github): plumb CI popover fields + branch protection cache
Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.
* feat(github): mock controller hooks for CI popover e2e
Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.
* feat(web): CI hover popover on PR top-bar button
Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.
PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.
* test(e2e): pr-topbar-popover scenarios A–F covering CI popover
11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.
Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.
* fix(github): address bot review comments on CI popover
- branch_protection.go: evict stale entries on read so the cache map
cannot grow unbounded over a long-running deployment; rewrite the
comment to drop the inaccurate singleflight claim (no dedup logic
exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
isNotFoundErr helper that tolerates the formats current and future
gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
un-fetched tail to the unresolved bucket so PRs with >100 threads
don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
(every branch overwrites it). Memoize useAddCheckToContext so
PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
usePopoverInteractions.
* fix(github): address remaining bot review comments + tests
Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
retargeted PR's branch-protection lookup keys off the new branch.
Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
Read scope) the same way as 404 — otherwise every poller tick burns
rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
on updated_at change), via a wasEnabledRef gate. Restores the
intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
bucket so clicking a "failed" workflow row navigates to a failing
job, not an earlier passing one.
Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
zero, base-branch retarget, change-publish for the new fields.
* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover
- mock_controller.go: extract associateTaskPRRequest struct,
buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
Multi-PR tasks currently use the existing DropdownMenu trigger
(no hover popover), so the aggregate component was dead code.
* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err
- pr-topbar-button.tsx: anchorRef was leftover from the dropped
click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
helpers covering each accepted format and a few non-matches so a
future gh CLI wording change is caught.
* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks
- service.go: mirror the ChecksPopulated/preserve dance for
UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
doesn't fetch review threads, so without this guard every REST
sync would clobber the GraphQL-populated value to 0 and emit a
spurious github.task_pr.updated event. The GraphQL path
(convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
service constructed via NewService, which always initialises the
cache. After a successful type assertion, the interface value
cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
covers the regression. Existing NewFieldsPublishEvent threads-change
case updated to set UnresolvedReviewThreadsPopulated.
* refactor(web): drop CI popover progress bar — last-updated is enough
The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.
* fix(web): background-sync PRFeedback so popover is fresh on first hover
The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.
Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.
* feat(web): CI status chip in chat status bar + tweak elapsed label
- New <PRStatusChip> renders a compact colored circle in the chat
status bar (left of the TodoIndicator, just above the input). Hover
opens the same PRCIPopover anchored to the chip's top edge so the
card expands upward — matching the user's "open bottom-up" ask. The
chip mounts the background-sync hook so the popover is fresh on
first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
the same regardless of how recently the cache was refreshed.
* fix(web): chip color was missing — use static class map for Tailwind JIT
\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.
* fix(web): chip uses currentColor SVG so the dot actually renders
The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.
* fix(web): redesign PR status chip — clipboard icon + status glyph
- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
passed → green CircleCheck, failed → red CircleX, in_progress →
yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
reuse already-compiled text-* utilities so colors render reliably.
* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard
Two bugs feeding the popover's "Approved (0)" misreport:
- Semantic: REST path set ReviewCount to len(reviews), which double-counts
authors who left multiple reviews and includes COMMENTED rows. Replace
with countApprovedReviewers — distinct authors whose latest review
state is APPROVED. GraphQL converter gets the parallel
countApprovedReviewerNodes helper and also populates PendingReviewCount
from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
suffix was always missing).
- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
on every cycle because the converter never set them. Add
ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
the same way ChecksPopulated already does, so a partial sync path
preserves the persisted values instead of resetting them to zero.
* fix(web): popover nitpicks (icon, review row, indent, badge)
- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
no approvals + no protection rule now show "Awaiting review" instead
of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
(pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
hid whether the job had even started.
* feat(web): stacked pass-rate progress bar in CI popover
Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.
* fix(web): right-align review counts + drop chip hover bg
- Move approval/required counts to the right edge of the review row
("Approved" on the left, "1 / 2" on the right), mirroring the
bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
background plate when the popover is open.
* fix(web): switch CI chip icon to IconChecklist
* fix(github): clear bot review-comment backlog
Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
checks_state === "success" in chipStatus precedence — a PR with
CI green but reviewers outstanding is no longer rendered as
"passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.
Refactor:
- client_helpers + graphql: extract reviewSample +
latestReviewStateByAuthor helper so the three near-identical
per-reviewer dedup loops share one implementation.
Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
virtual time, no real-sleep flake on CI.
* fix(web): clicking the CI chip no longer closes the popover
HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
1184 lines
31 KiB
Go
1184 lines
31 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
)
|
|
|
|
func TestParseLinkNext(t *testing.T) {
|
|
tests := []struct {
|
|
link string
|
|
want string
|
|
}{
|
|
{
|
|
link: `<https://api.github.com/repos/o/r/branches?page=2>; rel="next", <https://api.github.com/repos/o/r/branches?page=3>; rel="last"`,
|
|
want: "https://api.github.com/repos/o/r/branches?page=2",
|
|
},
|
|
{
|
|
link: `<https://api.github.com/repos/o/r/branches?page=3>; rel="last"`,
|
|
want: "",
|
|
},
|
|
{link: "", want: ""},
|
|
}
|
|
for _, tc := range tests {
|
|
got := parseLinkNext(tc.link)
|
|
if got != tc.want {
|
|
t.Errorf("parseLinkNext(%q) = %q, want %q", tc.link, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListRepoBranches_Pagination(t *testing.T) {
|
|
page := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
page++
|
|
if page == 1 {
|
|
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/owner/repo/branches?page=2>; rel="next"`, "http://"+r.Host))
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{{"name": "feature-a"}})
|
|
} else {
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{{"name": "main"}})
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orig := anonymousAPIBase
|
|
anonymousAPIBase = srv.URL
|
|
defer func() { anonymousAPIBase = orig }()
|
|
|
|
svc := &Service{client: &NoopClient{}}
|
|
branches, err := svc.ListRepoBranches(context.Background(), "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(branches) != 2 {
|
|
t.Fatalf("expected 2 branches across pages, got %d", len(branches))
|
|
}
|
|
// main should appear first regardless of which page it was fetched from.
|
|
if branches[0].Name != "main" {
|
|
t.Errorf("expected main first, got %q; branches: %+v", branches[0].Name, branches)
|
|
}
|
|
if branches[1].Name != "feature-a" {
|
|
t.Errorf("expected feature-a second, got %q", branches[1].Name)
|
|
}
|
|
}
|
|
|
|
func TestListRepoBranches_NoopClientFallback(t *testing.T) {
|
|
// A fake GitHub API that returns two branches for a public repo.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/repos/owner/repo/branches" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode([]map[string]any{
|
|
{"name": "main"},
|
|
{"name": "develop"},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orig := anonymousAPIBase
|
|
anonymousAPIBase = srv.URL
|
|
defer func() { anonymousAPIBase = orig }()
|
|
|
|
svc := &Service{client: &NoopClient{}}
|
|
branches, err := svc.ListRepoBranches(context.Background(), "owner", "repo")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(branches) != 2 {
|
|
t.Fatalf("expected 2 branches, got %d", len(branches))
|
|
}
|
|
if branches[0].Name != "main" || branches[1].Name != "develop" {
|
|
t.Errorf("unexpected branches: %+v", branches)
|
|
}
|
|
}
|
|
|
|
func TestListRepoBranches_NoopClientFallback_NotFound(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orig := anonymousAPIBase
|
|
anonymousAPIBase = srv.URL
|
|
defer func() { anonymousAPIBase = orig }()
|
|
|
|
svc := &Service{client: &NoopClient{}}
|
|
_, err := svc.ListRepoBranches(context.Background(), "owner", "private-repo")
|
|
if err == nil {
|
|
t.Fatal("expected error for private/missing repo")
|
|
}
|
|
var apiErr *GitHubAPIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected GitHubAPIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusNotFound {
|
|
t.Errorf("expected 404 status, got %d", apiErr.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSortBranchesMainFirst(t *testing.T) {
|
|
tests := []struct {
|
|
input []string
|
|
want []string
|
|
}{
|
|
{
|
|
input: []string{"feature-b", "main", "feature-a", "master"},
|
|
want: []string{"main", "master", "feature-a", "feature-b"},
|
|
},
|
|
{
|
|
input: []string{"develop", "master"},
|
|
want: []string{"master", "develop"},
|
|
},
|
|
{
|
|
input: []string{"feature-a", "feature-b"},
|
|
want: []string{"feature-a", "feature-b"},
|
|
},
|
|
{
|
|
input: []string{"main"},
|
|
want: []string{"main"},
|
|
},
|
|
{
|
|
input: []string{},
|
|
want: []string{},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
branches := make([]RepoBranch, len(tc.input))
|
|
for i, n := range tc.input {
|
|
branches[i] = RepoBranch{Name: n}
|
|
}
|
|
sortBranchesMainFirst(branches)
|
|
for i, b := range branches {
|
|
if b.Name != tc.want[i] {
|
|
t.Errorf("input=%v: got[%d]=%q, want %q", tc.input, i, b.Name, tc.want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetPR_NilClient(t *testing.T) {
|
|
svc := &Service{client: nil}
|
|
_, err := svc.GetPR(context.Background(), "owner", "repo", 1)
|
|
if err == nil {
|
|
t.Fatal("expected error when client is nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "github client not available") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetPRFeedback_NilClient(t *testing.T) {
|
|
svc := &Service{client: nil}
|
|
_, err := svc.GetPRFeedback(context.Background(), "owner", "repo", 1)
|
|
if err == nil {
|
|
t.Fatal("expected error when client is nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "github client not available") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParsePRURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantOwner string
|
|
wantRepo string
|
|
wantNumber int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "standard URL",
|
|
url: "https://github.com/owner/repo/pull/123",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 123,
|
|
},
|
|
{
|
|
name: "trailing slash",
|
|
url: "https://github.com/owner/repo/pull/456/",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 456,
|
|
},
|
|
{
|
|
name: "with query params",
|
|
url: "https://github.com/owner/repo/pull/789?diff=unified",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 789,
|
|
},
|
|
{
|
|
name: "with fragment",
|
|
url: "https://github.com/owner/repo/pull/42#discussion_r123",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 42,
|
|
},
|
|
{
|
|
name: "with query and fragment",
|
|
url: "https://github.com/owner/repo/pull/10?a=b#frag",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 10,
|
|
},
|
|
{
|
|
name: "with whitespace",
|
|
url: " https://github.com/owner/repo/pull/5 \n",
|
|
wantOwner: "owner",
|
|
wantRepo: "repo",
|
|
wantNumber: 5,
|
|
},
|
|
{
|
|
name: "no pull segment",
|
|
url: "https://github.com/owner/repo/issues/1",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid PR number",
|
|
url: "https://github.com/owner/repo/pull/abc",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too short path",
|
|
url: "/pull/1",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty owner",
|
|
url: "https://github.com//repo/pull/1",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
owner, repo, number, err := parsePRURL(tt.url)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if owner != tt.wantOwner {
|
|
t.Errorf("owner = %q, want %q", owner, tt.wantOwner)
|
|
}
|
|
if repo != tt.wantRepo {
|
|
t.Errorf("repo = %q, want %q", repo, tt.wantRepo)
|
|
}
|
|
if number != tt.wantNumber {
|
|
t.Errorf("number = %d, want %d", number, tt.wantNumber)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeOverallCheckStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
checks []CheckRun
|
|
want string
|
|
}{
|
|
{"empty", nil, ""},
|
|
{
|
|
"all completed success",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "success"},
|
|
{Status: "completed", Conclusion: "success"},
|
|
},
|
|
"success",
|
|
},
|
|
{
|
|
"one failure",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "success"},
|
|
{Status: "completed", Conclusion: "failure"},
|
|
},
|
|
"failure",
|
|
},
|
|
{
|
|
"failure takes priority over pending",
|
|
[]CheckRun{
|
|
{Status: "in_progress"},
|
|
{Status: "completed", Conclusion: "failure"},
|
|
},
|
|
"failure",
|
|
},
|
|
{
|
|
"pending when in progress",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "success"},
|
|
{Status: "in_progress"},
|
|
},
|
|
"pending",
|
|
},
|
|
{
|
|
"all in progress",
|
|
[]CheckRun{
|
|
{Status: "queued"},
|
|
{Status: "in_progress"},
|
|
},
|
|
"pending",
|
|
},
|
|
{
|
|
// Regression for the "CI pending" badge bug: GitHub shows
|
|
// "All checks have passed — 1 skipped, 20 successful" but
|
|
// we were returning pending because the skipped runs weren't
|
|
// treated as completed-successful.
|
|
"AllSkipped mixed with success returns success",
|
|
func() []CheckRun {
|
|
checks := make([]CheckRun, 21)
|
|
for i := 0; i < 20; i++ {
|
|
checks[i] = CheckRun{Status: "completed", Conclusion: "success"}
|
|
}
|
|
checks[20] = CheckRun{Status: "completed", Conclusion: "skipped"}
|
|
return checks
|
|
}(),
|
|
"success",
|
|
},
|
|
{
|
|
"only skipped returns empty (no signal)",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "skipped"},
|
|
{Status: "completed", Conclusion: "skipped"},
|
|
},
|
|
"",
|
|
},
|
|
{
|
|
"only neutral returns empty (no signal)",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "neutral"},
|
|
{Status: "completed", Conclusion: "neutral"},
|
|
},
|
|
"",
|
|
},
|
|
{
|
|
"unknown future conclusion treated as passing",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "stale"}, // hypothetical future enum
|
|
},
|
|
"success",
|
|
},
|
|
{
|
|
"neutral conclusion is ignored like skipped",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "success"},
|
|
{Status: "completed", Conclusion: "neutral"},
|
|
},
|
|
"success",
|
|
},
|
|
{
|
|
"cancelled conclusion counts as failure",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "success"},
|
|
{Status: "completed", Conclusion: "cancelled"},
|
|
},
|
|
"failure",
|
|
},
|
|
{
|
|
"timed_out conclusion counts as failure",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "timed_out"},
|
|
},
|
|
"failure",
|
|
},
|
|
{
|
|
"action_required conclusion counts as failure",
|
|
[]CheckRun{
|
|
{Status: "completed", Conclusion: "action_required"},
|
|
},
|
|
"failure",
|
|
},
|
|
{
|
|
"in_progress plus skipped returns pending (skipped ignored)",
|
|
[]CheckRun{
|
|
{Status: "in_progress"},
|
|
{Status: "completed", Conclusion: "skipped"},
|
|
},
|
|
"pending",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := computeOverallCheckStatus(tt.checks)
|
|
if got != tt.want {
|
|
t.Errorf("computeOverallCheckStatus() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeOverallReviewState(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
reviews []PRReview
|
|
want string
|
|
}{
|
|
{"empty", nil, ""},
|
|
{
|
|
"all approved",
|
|
[]PRReview{
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t1},
|
|
{Author: "bob", State: "APPROVED", CreatedAt: t1},
|
|
},
|
|
"approved",
|
|
},
|
|
{
|
|
"changes requested",
|
|
[]PRReview{
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t1},
|
|
{Author: "bob", State: "CHANGES_REQUESTED", CreatedAt: t1},
|
|
},
|
|
"changes_requested",
|
|
},
|
|
{
|
|
"latest per author wins",
|
|
[]PRReview{
|
|
{Author: "alice", State: "CHANGES_REQUESTED", CreatedAt: t1},
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t2},
|
|
},
|
|
"approved",
|
|
},
|
|
{
|
|
"pending when not all approved",
|
|
[]PRReview{
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t1},
|
|
{Author: "bob", State: "COMMENTED", CreatedAt: t1},
|
|
},
|
|
"pending",
|
|
},
|
|
{
|
|
"changes requested takes priority over pending",
|
|
[]PRReview{
|
|
{Author: "alice", State: "COMMENTED", CreatedAt: t1},
|
|
{Author: "bob", State: "CHANGES_REQUESTED", CreatedAt: t1},
|
|
},
|
|
"changes_requested",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := computeOverallReviewState(tt.reviews)
|
|
if got != tt.want {
|
|
t.Errorf("computeOverallReviewState() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountPendingReviews(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
t2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
reviews []PRReview
|
|
want int
|
|
}{
|
|
{"empty", nil, 0},
|
|
{
|
|
"no pending",
|
|
[]PRReview{
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t1},
|
|
},
|
|
0,
|
|
},
|
|
{
|
|
"one pending",
|
|
[]PRReview{
|
|
{Author: "alice", State: "PENDING", CreatedAt: t1},
|
|
},
|
|
1,
|
|
},
|
|
{
|
|
"commented counts as pending",
|
|
[]PRReview{
|
|
{Author: "alice", State: "COMMENTED", CreatedAt: t1},
|
|
},
|
|
1,
|
|
},
|
|
{
|
|
"latest per author wins",
|
|
[]PRReview{
|
|
{Author: "alice", State: "COMMENTED", CreatedAt: t1},
|
|
{Author: "alice", State: "APPROVED", CreatedAt: t2},
|
|
},
|
|
0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := countPendingReviews(tt.reviews)
|
|
if got != tt.want {
|
|
t.Errorf("countPendingReviews() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountPendingRequestedReviewers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pr *PR
|
|
want int
|
|
}{
|
|
{"nil pr", nil, 0},
|
|
{"none", &PR{}, 0},
|
|
{
|
|
"with requested reviewers",
|
|
&PR{
|
|
RequestedReviewers: []RequestedReviewer{
|
|
{Login: "alice", Type: reviewerTypeUser},
|
|
{Login: "core-team", Type: reviewerTypeTeam},
|
|
},
|
|
},
|
|
2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := countPendingRequestedReviewers(tt.pr)
|
|
if got != tt.want {
|
|
t.Errorf("countPendingRequestedReviewers() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeriveReviewSyncState(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
pr *PR
|
|
reviews []PRReview
|
|
wantState string
|
|
wantCount int
|
|
}{
|
|
{
|
|
name: "uses requested reviewers before review-derived pending",
|
|
pr: &PR{RequestedReviewers: []RequestedReviewer{{Login: "alice", Type: reviewerTypeUser}}},
|
|
reviews: []PRReview{{Author: "alice", State: reviewStateCommented, CreatedAt: t1}},
|
|
wantState: computedReviewStatePending,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "fallback to review-derived pending when no requests",
|
|
pr: &PR{},
|
|
reviews: []PRReview{{Author: "alice", State: reviewStateCommented, CreatedAt: t1}},
|
|
wantState: computedReviewStatePending,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "no submitted reviews but pending requests yields pending state",
|
|
pr: &PR{RequestedReviewers: []RequestedReviewer{{Login: "core", Type: reviewerTypeTeam}}},
|
|
reviews: nil,
|
|
wantState: computedReviewStatePending,
|
|
wantCount: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotState, gotCount := deriveReviewSyncState(tt.pr, tt.reviews)
|
|
if gotState != tt.wantState {
|
|
t.Errorf("deriveReviewSyncState() state = %q, want %q", gotState, tt.wantState)
|
|
}
|
|
if gotCount != tt.wantCount {
|
|
t.Errorf("deriveReviewSyncState() pending count = %d, want %d", gotCount, tt.wantCount)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindLatestCommentTime(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
t2 := time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)
|
|
t3 := time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
comments []PRComment
|
|
want *time.Time
|
|
}{
|
|
{"empty", nil, nil},
|
|
{
|
|
"single",
|
|
[]PRComment{{UpdatedAt: t1}},
|
|
&t1,
|
|
},
|
|
{
|
|
"multiple",
|
|
[]PRComment{
|
|
{UpdatedAt: t1},
|
|
{UpdatedAt: t2},
|
|
{UpdatedAt: t3},
|
|
},
|
|
&t2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := findLatestCommentTime(tt.comments)
|
|
if tt.want == nil {
|
|
if got != nil {
|
|
t.Errorf("expected nil, got %v", got)
|
|
}
|
|
return
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected non-nil, got nil")
|
|
}
|
|
if !got.Equal(*tt.want) {
|
|
t.Errorf("got %v, want %v", *got, *tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- mockEventBus for SyncTaskPR tests ---
|
|
|
|
type mockEventBus struct {
|
|
mu sync.Mutex
|
|
events []*bus.Event
|
|
}
|
|
|
|
func (m *mockEventBus) Publish(_ context.Context, _ string, event *bus.Event) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.events = append(m.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockEventBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEventBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEventBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEventBus) Close() {}
|
|
|
|
func (m *mockEventBus) IsConnected() bool { return true }
|
|
|
|
func (m *mockEventBus) publishedCount() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.events)
|
|
}
|
|
|
|
// setupSyncTest creates a Service backed by in-memory SQLite and a mock event bus.
|
|
func setupSyncTest(t *testing.T) (*Service, *Store, *mockEventBus) {
|
|
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() })
|
|
|
|
store, err := NewStore(sqlxDB, sqlxDB)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
eb := &mockEventBus{}
|
|
svc := NewService(nil, "pat", nil, store, eb, log)
|
|
|
|
return svc, store, eb
|
|
}
|
|
|
|
func TestSyncTaskPR_PublishesEventOnChange(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: 1,
|
|
PRURL: "https://github.com/owner/repo/pull/1",
|
|
PRTitle: "Initial",
|
|
HeadBranch: "feat",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
}); err != nil {
|
|
t.Fatalf("create task PR: %v", err)
|
|
}
|
|
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1,
|
|
Title: "Updated title",
|
|
State: "open",
|
|
RepoOwner: "owner",
|
|
RepoName: "repo",
|
|
},
|
|
}
|
|
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync task PR: %v", err)
|
|
}
|
|
|
|
if got := eb.publishedCount(); got != 1 {
|
|
t.Errorf("expected 1 event after change, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSyncTaskPR_NoEventWhenUnchanged(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: 1,
|
|
PRURL: "https://github.com/owner/repo/pull/1",
|
|
PRTitle: "Same title",
|
|
HeadBranch: "feat",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
Additions: 10,
|
|
Deletions: 5,
|
|
ReviewState: "approved",
|
|
ChecksState: "success",
|
|
ReviewCount: 2,
|
|
}); err != nil {
|
|
t.Fatalf("create task PR: %v", err)
|
|
}
|
|
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1,
|
|
Title: "Same title",
|
|
State: "open",
|
|
Additions: 10,
|
|
Deletions: 5,
|
|
RepoOwner: "owner",
|
|
RepoName: "repo",
|
|
},
|
|
ReviewState: "approved",
|
|
ChecksState: "success",
|
|
ReviewCount: 2,
|
|
}
|
|
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync task PR: %v", err)
|
|
}
|
|
|
|
if got := eb.publishedCount(); got != 0 {
|
|
t.Errorf("expected 0 events when unchanged, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSyncTaskPR_SecondIdenticalSyncNoEvent(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: 1,
|
|
PRURL: "https://github.com/owner/repo/pull/1",
|
|
PRTitle: "Original",
|
|
HeadBranch: "feat",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
}); err != nil {
|
|
t.Fatalf("create task PR: %v", err)
|
|
}
|
|
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1,
|
|
Title: "Changed",
|
|
State: "open",
|
|
RepoOwner: "owner",
|
|
RepoName: "repo",
|
|
},
|
|
}
|
|
|
|
// First sync: data changed -> event.
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("first sync: %v", err)
|
|
}
|
|
if got := eb.publishedCount(); got != 1 {
|
|
t.Fatalf("expected 1 event after first sync, got %d", got)
|
|
}
|
|
|
|
// Second sync with same data -> no additional event.
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("second sync: %v", err)
|
|
}
|
|
if got := eb.publishedCount(); got != 1 {
|
|
t.Errorf("expected still 1 event after identical second sync, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSyncTaskPR_PublishesEventOnMergeableStateChange(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: 1,
|
|
PRURL: "https://github.com/owner/repo/pull/1",
|
|
PRTitle: "Same",
|
|
HeadBranch: "feat",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
Additions: 3,
|
|
Deletions: 1,
|
|
ReviewState: "approved",
|
|
ChecksState: "success",
|
|
MergeableState: "blocked",
|
|
ReviewCount: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create task PR: %v", err)
|
|
}
|
|
|
|
// Only mergeable_state changes (blocked -> clean); everything else identical.
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1,
|
|
Title: "Same",
|
|
State: "open",
|
|
Additions: 3,
|
|
Deletions: 1,
|
|
RepoOwner: "owner",
|
|
RepoName: "repo",
|
|
},
|
|
ReviewState: "approved",
|
|
ChecksState: "success",
|
|
MergeableState: "clean",
|
|
ReviewCount: 1,
|
|
}
|
|
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
if got := eb.publishedCount(); got != 1 {
|
|
t.Errorf("expected 1 event after mergeable_state change, got %d", got)
|
|
}
|
|
|
|
stored, err := store.GetTaskPR(ctx, "t1")
|
|
if err != nil {
|
|
t.Fatalf("get task PR: %v", err)
|
|
}
|
|
if stored.MergeableState != "clean" {
|
|
t.Errorf("expected stored mergeable_state=clean, got %q", stored.MergeableState)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_ChecksPopulated_PreservesOnLightweightSync verifies the
|
|
// "preserve when batch sync didn't populate" path: a PRStatus with
|
|
// ChecksPopulated=false must NOT clobber the persisted ChecksTotal /
|
|
// ChecksPassing, even when status carries 0/0.
|
|
func TestSyncTaskPR_ChecksPopulated_PreservesOnLightweightSync(t *testing.T) {
|
|
svc, store, _ := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
PRNumber: 1,
|
|
PRURL: "https://github.com/owner/repo/pull/1",
|
|
HeadBranch: "feat",
|
|
BaseBranch: "main",
|
|
State: "open",
|
|
ChecksTotal: 10,
|
|
ChecksPassing: 7,
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "owner", RepoName: "repo"},
|
|
ChecksTotal: 0,
|
|
ChecksPassing: 0,
|
|
ChecksPopulated: false, // batched GraphQL path: didn't count
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, err := store.GetTaskPR(ctx, "t1")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if stored.ChecksTotal != 10 || stored.ChecksPassing != 7 {
|
|
t.Fatalf("counts clobbered: total=%d passing=%d (want 10/7)", stored.ChecksTotal, stored.ChecksPassing)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_ChecksPopulated_AcceptsRealZero verifies the design fix:
|
|
// when the rich sync path explicitly reports 0/0 (workflows removed from
|
|
// the PR), we DO honor the new value instead of clinging to stale totals.
|
|
func TestSyncTaskPR_ChecksPopulated_AcceptsRealZero(t *testing.T) {
|
|
svc, store, _ := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open",
|
|
ChecksTotal: 5, ChecksPassing: 5,
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
ChecksTotal: 0,
|
|
ChecksPassing: 0,
|
|
ChecksPopulated: true, // REST path: counted to zero
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, _ := store.GetTaskPR(ctx, "t1")
|
|
if stored.ChecksTotal != 0 || stored.ChecksPassing != 0 {
|
|
t.Fatalf("real-zero not honored: total=%d passing=%d", stored.ChecksTotal, stored.ChecksPassing)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync covers the same
|
|
// preserve-on-not-populated dance for ReviewCount/PendingReviewCount as the
|
|
// checks/threads variants. A partial sync path (status.ReviewCountsPopulated
|
|
// = false) must not reset the popover's "Approved (N)" line to zero.
|
|
func TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open",
|
|
ReviewCount: 2, PendingReviewCount: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
ReviewCount: 0,
|
|
PendingReviewCount: 0,
|
|
ReviewCountsPopulated: false,
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, _ := store.GetTaskPR(ctx, "t1")
|
|
if stored.ReviewCount != 2 || stored.PendingReviewCount != 1 {
|
|
t.Fatalf("review counts clobbered: review=%d pending=%d (want 2/1)",
|
|
stored.ReviewCount, stored.PendingReviewCount)
|
|
}
|
|
if got := eb.publishedCount(); got != 0 {
|
|
t.Fatalf("expected no event when nothing changed, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_ReviewCounts_AcceptsRealZero is the symmetric case: when the
|
|
// caller actually counted reviewers and reports zero, we honor it (e.g. all
|
|
// approvals dismissed) instead of clinging to the previous non-zero values.
|
|
func TestSyncTaskPR_ReviewCounts_AcceptsRealZero(t *testing.T) {
|
|
svc, store, _ := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open",
|
|
ReviewCount: 2, PendingReviewCount: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
ReviewCount: 0,
|
|
PendingReviewCount: 0,
|
|
ReviewCountsPopulated: true,
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, _ := store.GetTaskPR(ctx, "t1")
|
|
if stored.ReviewCount != 0 || stored.PendingReviewCount != 0 {
|
|
t.Fatalf("real-zero not honored: review=%d pending=%d",
|
|
stored.ReviewCount, stored.PendingReviewCount)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync verifies the
|
|
// REST single-PR poller (which doesn't fetch review threads) does NOT
|
|
// clobber the value set by the GraphQL batched poller. Without the
|
|
// UnresolvedReviewThreadsPopulated guard, every REST sync would flip the
|
|
// count to 0 and emit a spurious github.task_pr.updated event.
|
|
func TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open",
|
|
UnresolvedReviewThreads: 3,
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
UnresolvedReviewThreads: 0,
|
|
UnresolvedReviewThreadsPopulated: false, // REST path: didn't look
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, _ := store.GetTaskPR(ctx, "t1")
|
|
if stored.UnresolvedReviewThreads != 3 {
|
|
t.Fatalf("threads clobbered: got %d, want 3", stored.UnresolvedReviewThreads)
|
|
}
|
|
if got := eb.publishedCount(); got != 0 {
|
|
t.Fatalf("expected no event when nothing changed, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_BaseBranchRetarget covers the case where a PR is retargeted
|
|
// to a different base branch upstream — the new branch must be persisted so
|
|
// future branch-protection lookups key off the right value.
|
|
func TestSyncTaskPR_BaseBranchRetarget(t *testing.T) {
|
|
svc, store, _ := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &TaskPR{
|
|
TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open",
|
|
}); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
status := &PRStatus{
|
|
PR: &PR{
|
|
Number: 1, State: "open", RepoOwner: "o", RepoName: "r",
|
|
BaseBranch: "develop",
|
|
},
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
stored, _ := store.GetTaskPR(ctx, "t1")
|
|
if stored.BaseBranch != "develop" {
|
|
t.Fatalf("base branch not updated: got %q want develop", stored.BaseBranch)
|
|
}
|
|
}
|
|
|
|
// TestSyncTaskPR_NewFieldsPublishEvent guards that a change to any of the
|
|
// CI-popover-relevant fields (UnresolvedReviewThreads, RequiredReviews,
|
|
// ChecksTotal, ChecksPassing) triggers a github.task_pr.updated event so the
|
|
// frontend popover refreshes via WS.
|
|
func TestSyncTaskPR_NewFieldsPublishEvent(t *testing.T) {
|
|
required2 := 2
|
|
cases := []struct {
|
|
name string
|
|
init TaskPR
|
|
status PRStatus
|
|
}{
|
|
{
|
|
name: "unresolved threads change",
|
|
init: TaskPR{TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open"},
|
|
status: PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
UnresolvedReviewThreads: 3,
|
|
UnresolvedReviewThreadsPopulated: true,
|
|
},
|
|
},
|
|
{
|
|
name: "required reviews change",
|
|
init: TaskPR{TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open"},
|
|
status: PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
RequiredReviews: &required2,
|
|
},
|
|
},
|
|
{
|
|
name: "checks total change",
|
|
init: TaskPR{TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, PRURL: "u",
|
|
HeadBranch: "feat", BaseBranch: "main", State: "open"},
|
|
status: PRStatus{
|
|
PR: &PR{Number: 1, State: "open", RepoOwner: "o", RepoName: "r"},
|
|
ChecksTotal: 5,
|
|
ChecksPassing: 3,
|
|
ChecksPopulated: true,
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
svc, store, eb := setupSyncTest(t)
|
|
ctx := context.Background()
|
|
if err := store.CreateTaskPR(ctx, &tc.init); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if err := svc.SyncTaskPR(ctx, "t1", &tc.status); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
if got := eb.publishedCount(); got != 1 {
|
|
t.Fatalf("expected 1 event, got %d", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTimeEqual(t *testing.T) {
|
|
now := time.Now()
|
|
later := now.Add(time.Second)
|
|
|
|
tests := []struct {
|
|
name string
|
|
a, b *time.Time
|
|
want bool
|
|
}{
|
|
{"both nil", nil, nil, true},
|
|
{"a nil", nil, &now, false},
|
|
{"b nil", &now, nil, false},
|
|
{"equal", &now, &now, true},
|
|
{"not equal", &now, &later, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := timeEqual(tt.a, tt.b); got != tt.want {
|
|
t.Errorf("timeEqual() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|