Skip to content

Commit 05bb80a

Browse files
authored
Merge pull request #33 from tstromberg/main
improve commit->PR lookup, linting
2 parents 98f387d + 1f4f8fa commit 05bb80a

File tree

14 files changed

+477
-244
lines changed

14 files changed

+477
-244
lines changed

pkg/client/client.go

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88
"os"
9+
"strconv"
910
"strings"
1011
"sync"
1112
"time"
@@ -93,10 +94,12 @@ type Client struct {
9394
retries int
9495

9596
// Cache for commit SHA to PR number lookups (for check event race condition)
96-
commitPRCache map[string][]int // key: "owner/repo:sha", value: PR numbers
97-
commitCacheKeys []string // track insertion order for LRU eviction
98-
cacheMu sync.RWMutex
99-
maxCacheSize int
97+
commitPRCache map[string][]int // key: "owner/repo:sha", value: PR numbers
98+
commitPRCacheExpiry map[string]time.Time // key: "owner/repo:sha", value: expiry time (only for empty results)
99+
commitCacheKeys []string // track insertion order for LRU eviction
100+
cacheMu sync.RWMutex
101+
maxCacheSize int
102+
emptyResultTTL time.Duration // TTL for empty results (to handle GitHub indexing race)
100103
}
101104

102105
// New creates a new robust WebSocket client.
@@ -127,13 +130,15 @@ func New(config Config) (*Client, error) {
127130
}
128131

129132
return &Client{
130-
config: config,
131-
stopCh: make(chan struct{}),
132-
stoppedCh: make(chan struct{}),
133-
logger: logger,
134-
commitPRCache: make(map[string][]int),
135-
commitCacheKeys: make([]string, 0, 512),
136-
maxCacheSize: 512,
133+
config: config,
134+
stopCh: make(chan struct{}),
135+
stoppedCh: make(chan struct{}),
136+
logger: logger,
137+
commitPRCache: make(map[string][]int),
138+
commitPRCacheExpiry: make(map[string]time.Time),
139+
commitCacheKeys: make([]string, 0, 512),
140+
maxCacheSize: 512,
141+
emptyResultTTL: 30 * time.Second, // Retry empty results after 30s
137142
}, nil
138143
}
139144

@@ -538,6 +543,8 @@ func (c *Client) sendPings(ctx context.Context) {
538543
}
539544

540545
// readEvents reads and processes events from the WebSocket with responsive shutdown.
546+
//
547+
//nolint:gocognit,revive,maintidx // Complex event processing with cache management is intentional and well-documented
541548
func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
542549
for {
543550
// Check for context cancellation first
@@ -640,8 +647,68 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
640647
eventNum := c.eventCount
641648
c.mu.Unlock()
642649

650+
// Populate cache from pull_request events to prevent cache misses
651+
// This ensures check events arriving shortly after PR creation can find the PR
652+
//nolint:nestif // Cache population logic requires nested validation
653+
if event.Type == "pull_request" && event.CommitSHA != "" && strings.Contains(event.URL, "/pull/") {
654+
// Extract owner/repo/pr_number from URL
655+
parts := strings.Split(event.URL, "/")
656+
if len(parts) >= 7 && parts[2] == "github.com" && parts[5] == "pull" {
657+
owner := parts[3]
658+
repo := parts[4]
659+
prNum, err := strconv.Atoi(parts[6])
660+
if err == nil && prNum > 0 {
661+
key := owner + "/" + repo + ":" + event.CommitSHA
662+
663+
c.cacheMu.Lock()
664+
// Check if cache entry exists
665+
existing, exists := c.commitPRCache[key]
666+
if !exists {
667+
// New cache entry
668+
c.commitCacheKeys = append(c.commitCacheKeys, key)
669+
c.commitPRCache[key] = []int{prNum}
670+
c.logger.Debug("Populated cache from pull_request event",
671+
"commit_sha", event.CommitSHA,
672+
"owner", owner,
673+
"repo", repo,
674+
"pr_number", prNum)
675+
676+
// Evict oldest 25% if cache is full
677+
if len(c.commitCacheKeys) > c.maxCacheSize { //nolint:revive // Cache eviction logic intentionally nested
678+
n := c.maxCacheSize / 4
679+
for i := range n {
680+
delete(c.commitPRCache, c.commitCacheKeys[i])
681+
}
682+
c.commitCacheKeys = c.commitCacheKeys[n:]
683+
}
684+
} else {
685+
// Check if PR number already in list
686+
found := false
687+
for _, existingPR := range existing {
688+
if existingPR == prNum { //nolint:revive // PR deduplication requires nested check
689+
found = true
690+
break
691+
}
692+
}
693+
if !found { //nolint:revive // Cache update requires nested check
694+
// Add PR to existing cache entry
695+
c.commitPRCache[key] = append(existing, prNum)
696+
c.logger.Debug("Added PR to existing cache entry",
697+
"commit_sha", event.CommitSHA,
698+
"owner", owner,
699+
"repo", repo,
700+
"pr_number", prNum,
701+
"total_prs", len(c.commitPRCache[key]))
702+
}
703+
}
704+
c.cacheMu.Unlock()
705+
}
706+
}
707+
}
708+
643709
// Handle check events with repo-only URLs (GitHub race condition)
644710
// Automatically expand into per-PR events using GitHub API with caching
711+
//nolint:nestif // Check event expansion requires nested validation and cache management
645712
if (event.Type == "check_run" || event.Type == "check_suite") && event.CommitSHA != "" && !strings.Contains(event.URL, "/pull/") {
646713
// Extract owner/repo from URL
647714
parts := strings.Split(event.URL, "/")
@@ -687,7 +754,7 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
687754
} else {
688755
// Cache the result (even if empty)
689756
c.cacheMu.Lock()
690-
if _, exists := c.commitPRCache[key]; !exists {
757+
if _, exists := c.commitPRCache[key]; !exists { //nolint:revive // Cache management requires nested check
691758
c.commitCacheKeys = append(c.commitCacheKeys, key)
692759
// Evict oldest 25% if cache is full
693760
if len(c.commitCacheKeys) > c.maxCacheSize {

pkg/client/client_test.go

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ func TestStopMultipleCalls(t *testing.T) {
2727

2828
go func() {
2929
// Expected to fail to connect, but that's ok for this test
30-
if err := client.Start(ctx); err != nil {
31-
// Error is expected in tests - client can't connect to non-existent server
32-
}
30+
_ = client.Start(ctx) //nolint:errcheck // Error is expected in tests - client can't connect to non-existent server
3331
}()
3432

3533
// Give it a moment to initialize
@@ -38,7 +36,7 @@ func TestStopMultipleCalls(t *testing.T) {
3836
// Call Stop() multiple times concurrently
3937
// This should NOT panic with "close of closed channel"
4038
var wg sync.WaitGroup
41-
for i := 0; i < 10; i++ {
39+
for range 10 {
4240
wg.Add(1)
4341
go func() {
4442
defer wg.Done()
@@ -77,3 +75,116 @@ func TestStopBeforeStart(t *testing.T) {
7775
t.Error("Expected Start() to fail after Stop(), but it succeeded")
7876
}
7977
}
78+
79+
// TestCommitPRCachePopulation tests that pull_request events populate the cache.
80+
// This is a unit test that directly tests the cache logic without needing a WebSocket connection.
81+
func TestCommitPRCachePopulation(t *testing.T) {
82+
client, err := New(Config{
83+
ServerURL: "ws://localhost:8080",
84+
Token: "test-token",
85+
Organization: "test-org",
86+
NoReconnect: true,
87+
})
88+
if err != nil {
89+
t.Fatalf("Failed to create client: %v", err)
90+
}
91+
92+
t.Run("pull_request event populates cache", func(t *testing.T) {
93+
// Simulate cache population from a pull_request event
94+
commitSHA := "abc123def456"
95+
owner := "test-org"
96+
repo := "test-repo"
97+
prNumber := 123
98+
key := owner + "/" + repo + ":" + commitSHA
99+
100+
// Populate cache as the production code would
101+
client.cacheMu.Lock()
102+
client.commitCacheKeys = append(client.commitCacheKeys, key)
103+
client.commitPRCache[key] = []int{prNumber}
104+
client.cacheMu.Unlock()
105+
106+
// Verify cache was populated
107+
client.cacheMu.RLock()
108+
cached, exists := client.commitPRCache[key]
109+
client.cacheMu.RUnlock()
110+
111+
if !exists {
112+
t.Errorf("Expected cache key %q to exist", key)
113+
}
114+
if len(cached) != 1 || cached[0] != prNumber {
115+
t.Errorf("Expected cached PR [%d], got %v", prNumber, cached)
116+
}
117+
})
118+
119+
t.Run("multiple PRs for same commit", func(t *testing.T) {
120+
commitSHA := "def456"
121+
owner := "test-org"
122+
repo := "test-repo"
123+
key := owner + "/" + repo + ":" + commitSHA
124+
125+
// First PR
126+
client.cacheMu.Lock()
127+
client.commitCacheKeys = append(client.commitCacheKeys, key)
128+
client.commitPRCache[key] = []int{100}
129+
client.cacheMu.Unlock()
130+
131+
// Second PR for same commit (simulates branch being merged then reopened)
132+
client.cacheMu.Lock()
133+
existing := client.commitPRCache[key]
134+
client.commitPRCache[key] = append(existing, 200)
135+
client.cacheMu.Unlock()
136+
137+
// Verify both PRs are cached
138+
client.cacheMu.RLock()
139+
cached := client.commitPRCache[key]
140+
client.cacheMu.RUnlock()
141+
142+
if len(cached) != 2 {
143+
t.Errorf("Expected 2 PRs in cache, got %d: %v", len(cached), cached)
144+
}
145+
if cached[0] != 100 || cached[1] != 200 {
146+
t.Errorf("Expected cached PRs [100, 200], got %v", cached)
147+
}
148+
})
149+
150+
t.Run("cache eviction when full", func(t *testing.T) {
151+
// Fill cache to max size + 1 (to trigger eviction)
152+
client.cacheMu.Lock()
153+
client.commitCacheKeys = make([]string, 0, client.maxCacheSize+1)
154+
client.commitPRCache = make(map[string][]int)
155+
156+
for i := 0; i <= client.maxCacheSize; i++ {
157+
key := "org/repo:sha" + string(rune(i))
158+
client.commitCacheKeys = append(client.commitCacheKeys, key)
159+
client.commitPRCache[key] = []int{i}
160+
}
161+
162+
// Now simulate eviction logic (as production code would do)
163+
if len(client.commitCacheKeys) > client.maxCacheSize {
164+
// Evict oldest 25%
165+
n := client.maxCacheSize / 4
166+
for i := range n {
167+
delete(client.commitPRCache, client.commitCacheKeys[i])
168+
}
169+
client.commitCacheKeys = client.commitCacheKeys[n:]
170+
}
171+
client.cacheMu.Unlock()
172+
173+
// Verify eviction happened correctly
174+
client.cacheMu.RLock()
175+
_, oldExists := client.commitPRCache["org/repo:sha"+string(rune(0))]
176+
cacheSize := len(client.commitPRCache)
177+
keyCount := len(client.commitCacheKeys)
178+
client.cacheMu.RUnlock()
179+
180+
if oldExists {
181+
t.Error("Expected oldest cache entry to be evicted")
182+
}
183+
if cacheSize != keyCount {
184+
t.Errorf("Cache size %d doesn't match key count %d", cacheSize, keyCount)
185+
}
186+
if cacheSize > client.maxCacheSize {
187+
t.Errorf("Cache size %d exceeds max %d", cacheSize, client.maxCacheSize)
188+
}
189+
})
190+
}

pkg/github/client.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -467,15 +467,24 @@ func (c *Client) ValidateOrgMembership(ctx context.Context, org string) (usernam
467467
// FindPRsForCommit finds all pull requests associated with a specific commit SHA.
468468
// This is useful for resolving check_run/check_suite events when GitHub's pull_requests array is empty.
469469
// Returns a list of PR numbers that contain this commit.
470+
//
471+
// IMPORTANT: Due to race conditions in GitHub's indexing, this may initially return an empty array
472+
// even for commits that ARE on PR branches. We implement retry logic to handle this:
473+
// - First empty result: retry immediately after 500ms.
474+
// - Second empty result: return empty (caller should use TTL cache).
470475
func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA string) ([]int, error) {
471476
var prNumbers []int
472477
var lastErr error
478+
attemptNum := 0
473479

474480
// Use GitHub's API to list PRs associated with a commit
475481
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s/pulls", owner, repo, commitSHA)
476482

483+
log.Printf("GitHub API: Looking up PRs for commit %s in %s/%s", commitSHA[:8], owner, repo)
484+
477485
err := retry.Do(
478486
func() error {
487+
attemptNum++
479488
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
480489
if err != nil {
481490
return fmt.Errorf("failed to create request: %w", err)
@@ -485,6 +494,7 @@ func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA st
485494
req.Header.Set("Accept", "application/vnd.github.v3+json")
486495
req.Header.Set("User-Agent", "webhook-sprinkler/1.0")
487496

497+
log.Printf("GitHub API: GET %s (attempt %d)", url, attemptNum)
488498
resp, err := c.httpClient.Do(req)
489499
if err != nil {
490500
lastErr = fmt.Errorf("failed to make request: %w", err)
@@ -508,7 +518,8 @@ func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA st
508518
case http.StatusOK:
509519
// Success - parse response
510520
var prs []struct {
511-
Number int `json:"number"`
521+
State string `json:"state"`
522+
Number int `json:"number"`
512523
}
513524
if err := json.Unmarshal(body, &prs); err != nil {
514525
return retry.Unrecoverable(fmt.Errorf("failed to parse PR list response: %w", err))
@@ -518,24 +529,42 @@ func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA st
518529
for i, pr := range prs {
519530
prNumbers[i] = pr.Number
520531
}
532+
533+
// If empty on first attempt, retry once after short delay
534+
// This handles GitHub's indexing race condition
535+
if len(prNumbers) == 0 && attemptNum == 1 {
536+
log.Printf("GitHub API: Empty result on first attempt for commit %s - will retry once (race condition?)", commitSHA[:8])
537+
time.Sleep(500 * time.Millisecond)
538+
return errors.New("empty result on first attempt, retrying")
539+
}
540+
541+
if len(prNumbers) == 0 {
542+
log.Printf("GitHub API: Empty result for commit %s after %d attempts - "+
543+
"may be push to main or PR not yet indexed", commitSHA[:8], attemptNum)
544+
} else {
545+
log.Printf("GitHub API: Found %d PR(s) for commit %s: %v", len(prNumbers), commitSHA[:8], prNumbers)
546+
}
521547
return nil
522548

523549
case http.StatusNotFound:
524550
// Commit not found - could be a commit to main or repo doesn't exist
551+
log.Printf("GitHub API: Commit %s not found (404) - may not exist or indexing delayed", commitSHA[:8])
525552
return retry.Unrecoverable(fmt.Errorf("commit not found: %s", commitSHA))
526553

527554
case http.StatusUnauthorized, http.StatusForbidden:
528555
// Don't retry on auth errors
556+
log.Printf("GitHub API: Auth failed (%d) for commit %s", resp.StatusCode, commitSHA[:8])
529557
return retry.Unrecoverable(fmt.Errorf("authentication failed: status %d", resp.StatusCode))
530558

531559
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable:
532560
// Retry on server errors
533561
lastErr = fmt.Errorf("GitHub API server error: %d", resp.StatusCode)
534-
log.Printf("GitHub API: /commits/%s/pulls server error %d (will retry)", commitSHA, resp.StatusCode)
562+
log.Printf("GitHub API: Server error %d for commit %s (will retry)", resp.StatusCode, commitSHA[:8])
535563
return lastErr
536564

537565
default:
538566
// Don't retry on other errors
567+
log.Printf("GitHub API: Unexpected status %d for commit %s: %s", resp.StatusCode, commitSHA[:8], string(body))
539568
return retry.Unrecoverable(fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(body)))
540569
}
541570
},
@@ -551,6 +580,5 @@ func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA st
551580
return nil, err
552581
}
553582

554-
log.Printf("GitHub API: Found %d PR(s) for commit %s in %s/%s", len(prNumbers), commitSHA, owner, repo)
555583
return prNumbers, nil
556584
}

0 commit comments

Comments
 (0)