Files
kandev/apps/backend/internal/github/service_test.go
T
João Salavisa c11a6bbfd2 feat(github): add issue watcher with task creation and auto-cleanup (#672)
* feat(github): add issue watcher with task creation and auto-cleanup

Add a GitHub Issue Watcher feature that polls for new issues matching
configurable filters and automatically creates tasks for them. This
complements the existing PR Review Watcher with the same patterns.

Backend:
- Issue watch CRUD (models, store, service, controller, WS handlers)
- Poller integration with configurable intervals
- Event-driven task creation via orchestrator
- Repository resolution and worktree support for issue tasks
- Auto-cleanup of tasks for closed issues (CleanupClosedIssueTasks)
- GetIssueState client method across all implementations
- Default branch auto-detection from cloned repo HEAD

Frontend:
- Issue watch settings UI (table, dialog, placeholders)
- Issue link icon on task cards (green circle-dot)
- API client, store slice, and hook for issue watches
- SSR and WS hydration of issue metadata

* fix: resolve golangci-lint errors (goconst, nestif, dupl) and prettier formatting

* fix(github): fix issue watch update shape, trigger key, and label quoting

- httpUpdateIssueWatch now returns the full IssueWatch object instead of
  {updated:true} so the frontend store correctly refreshes after edits
- httpTriggerIssueWatch returns new_issues_found to match TriggerIssueResponse
  type; was returning new_issues so the toast count was always 0
- buildIssueFilter uses conditional quoting instead of fmt.Sprintf %q to
  avoid Go escape sequences in GitHub search qualifiers

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* fix: resolve eslint warnings (max-lines-per-function, no-nested-ternary)

* test(github): add issue watcher tests and fix review feedback

- Add TestInterpolateIssuePrompt (4 cases: empty/all/none/partial)
- Add TestCreateIssueTask (3 cases: skip/reserve+assign/release on fail)
- Add TestShouldDeleteIssueTask (4 cases: open/closed+sessions/closed/error)
- Fix WS response key new_issues -> new_issues_found
- Fix fmt.Printf -> fmt.Fprintf(os.Stderr) in store.go
- Add CleanupClosedIssueTasks call in WS trigger handler

* fix: address PR review feedback and CI failures

- Split test files to fix revive file-length-limit (service_test.go,
  event_handlers_github_test.go)
- Add session checker guard in CleanupClosedIssueTasks
- Normalize poll intervals on UpdateIssueWatch
- Add cleanup call in TriggerAllIssueChecks loop
- Log cleanup errors in controller and WS handler
- Prefer refs/remotes/origin/HEAD in detectGitDefaultBranch
- Add aria-label to issue task icon
- Fix TypeScript type errors: add linkedIssues to GitHubSlice,
  fix hook exports, remove invalid imports

* fix: split test files exceeding revive limit and clean up minor issues

- Split service_test.go (883→779 lines): move TestTriggerPRSync_* to
  service_pr_sync_test.go
- Split event_handlers_github_test.go (806→677 lines): move
  TestCreateReviewTask_* and helpers to event_handlers_github_review_test.go
- Remove redundant s.taskSessionChecker nil-check inside shouldDeleteIssueTask
  (early guard at CleanupClosedIssueTasks already ensures it is non-nil)
- Remove unreachable dead guard in detectAndPersistDefaultBranch
  (repo.DefaultBranch is always "" at that call site)
- Fix bogus #0 issue badge in task-session-sidebar.tsx when issueNumber is absent

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* fix: address Claude review — poll clamp and prompt injection

- Handle v <= 0 in UpdateIssueWatch poll interval (fall back to default)
- Remove {{issue.body}} from default template to prevent prompt injection
- Update test expected output for new default template

* fix: remove {{issue.body}} from frontend DEFAULT_ISSUE_WATCH_PROMPT

Mirrors the backend fix — the UI pre-fills the prompt so the backend
fallback was never reached. The placeholder stays in
ISSUE_WATCH_PLACEHOLDERS for explicit opt-in.

* feat(github): add isIssueWatch sidebar filter dimension

Add isIssueWatch boolean to KanbanState, TaskSwitcherItem, and sidebar
filter registry — mirroring the isPRReview pattern. Issue-watch tasks
(created from issue_watch_id in metadata) can now be explicitly filtered
in the sidebar, just like PR review tasks.

Files changed:
- sidebar-view-types: add isIssueWatch dimension
- filter-dimension-registry: add Issue watch filter meta
- apply-view: add isIssueWatch extractor
- kanban types: add isIssueWatch field
- TaskSwitcherItem: add isIssueWatch field
- use-all-workflow-snapshots: derive isIssueWatch from metadata
- ssr/mapper: derive isIssueWatch from metadata
- task-session-sidebar: pass isIssueWatch, extract helpers to fix complexity

* fix: correct deleteIssueWatch response type to match backend

Backend httpDeleteIssueWatch returns {"deleted": true}; the frontend
type was { success: boolean } — a mismatch with no functional impact
(return value is ignored) but wrong per TypeScript contract.

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* refactor: extract metadata helpers to shared lib/metadata-utils.ts

- Move isPRReviewFromMetadata, isIssueWatchFromMetadata, and
  issueFieldsFromMetadata to a shared module to eliminate duplicate
  definitions (sonarjs/no-identical-functions).
- Fix deleteIssueWatch response type: { success: boolean } -> { deleted: boolean }
  to match the actual backend response.

* style: fix prettier formatting

* test(web): add tests for isIssueWatch filter dimension and metadata-utils

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: fix prettier formatting in test files

* fix(github): cascade-delete issue watch tasks on watch deletion and add isPRReview to SSR mapper

- DeleteIssueWatch now wraps in a transaction and deletes related
  github_issue_watch_tasks rows before removing the watch; prevents
  orphaned dedup rows accumulating after a watch is deleted
- snapshotToState in mapper.ts now also sets isPRReview from metadata,
  matching the mapping already done in use-all-workflow-snapshots.ts

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* style: extract duplicate string constant in metadata-utils test

* style: extract repeated WS error strings into constants in handlers.go

goconst min-occurrences: 3 — 'invalid payload' (6x) and 'id required' (4x)
both exceed the threshold. Extract to package-level constants.

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* style: extract repeated issue key strings into constants in event_handlers_github.go

goconst min-occurrences: 3 — 'issue_number' (9x) and 'issue_watch_id' (5x)
both newly introduced in this PR exceed the threshold.

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

* style: fix prettier formatting in metadata-utils test

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:32:46 +01:00

780 lines
18 KiB
Go

package github
import (
"context"
"database/sql"
"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 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)
}
}
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)
}
})
}
}