Skip to content

Commit f65df9f

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
transparent test->PR lookups
1 parent fb513d2 commit f65df9f

File tree

6 files changed

+298
-17
lines changed

6 files changed

+298
-17
lines changed

go.sum

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
github.com/codeGROOVE-dev/retry v1.2.0 h1:xYpYPX2PQZmdHwuiQAGGzsBm392xIMl4nfMEFApQnu8=
22
github.com/codeGROOVE-dev/retry v1.2.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E=
3-
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
4-
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
53
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
64
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
7-
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
8-
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
9-
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
10-
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
115
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
126
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
13-
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
14-
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
157
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
168
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=

pkg/client/client.go

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/codeGROOVE-dev/retry"
14+
"github.com/codeGROOVE-dev/sprinkler/pkg/github"
1415
"golang.org/x/net/websocket"
1516
)
1617

@@ -45,8 +46,9 @@ type Event struct {
4546
Timestamp time.Time `json:"timestamp"`
4647
Raw map[string]any
4748
Type string `json:"type"`
48-
URL string `json:"url"`
49+
URL string `json:"url"` // PR URL (or repo URL for check events with race condition)
4950
DeliveryID string `json:"delivery_id,omitempty"`
51+
CommitSHA string `json:"commit_sha,omitempty"` // Commit SHA for check events
5052
}
5153

5254
// Config holds the configuration for the client.
@@ -89,6 +91,12 @@ type Client struct {
8991
writeCh chan any // Channel for serializing all writes
9092
eventCount int
9193
retries int
94+
95+
// 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
92100
}
93101

94102
// New creates a new robust WebSocket client.
@@ -119,10 +127,13 @@ func New(config Config) (*Client, error) {
119127
}
120128

121129
return &Client{
122-
config: config,
123-
stopCh: make(chan struct{}),
124-
stoppedCh: make(chan struct{}),
125-
logger: logger,
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,
126137
}, nil
127138
}
128139

@@ -620,18 +631,119 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
620631
event.DeliveryID = deliveryID
621632
}
622633

634+
if commitSHA, ok := response["commit_sha"].(string); ok {
635+
event.CommitSHA = commitSHA
636+
}
637+
623638
c.mu.Lock()
624639
c.eventCount++
625640
eventNum := c.eventCount
626641
c.mu.Unlock()
627642

643+
// Handle check events with repo-only URLs (GitHub race condition)
644+
// Automatically expand into per-PR events using GitHub API with caching
645+
if (event.Type == "check_run" || event.Type == "check_suite") && event.CommitSHA != "" && !strings.Contains(event.URL, "/pull/") {
646+
// Extract owner/repo from URL
647+
parts := strings.Split(event.URL, "/")
648+
if len(parts) >= 5 && parts[2] == "github.com" {
649+
owner := parts[3]
650+
repo := parts[4]
651+
key := owner + "/" + repo + ":" + event.CommitSHA
652+
653+
// Check cache first
654+
c.cacheMu.RLock()
655+
cached, ok := c.commitPRCache[key]
656+
c.cacheMu.RUnlock()
657+
658+
var prs []int
659+
if ok {
660+
// Cache hit - return copy to prevent external modifications
661+
prs = make([]int, len(cached))
662+
copy(prs, cached)
663+
c.logger.Info("Check event with repo URL - using cached PR lookup",
664+
"commit_sha", event.CommitSHA,
665+
"repo_url", event.URL,
666+
"type", event.Type,
667+
"pr_count", len(prs),
668+
"cache_hit", true)
669+
} else {
670+
// Cache miss - look up via GitHub API
671+
c.logger.Info("Check event with repo URL - looking up PRs via GitHub API",
672+
"commit_sha", event.CommitSHA,
673+
"repo_url", event.URL,
674+
"type", event.Type,
675+
"cache_hit", false)
676+
677+
gh := github.NewClient(c.config.Token)
678+
var err error
679+
prs, err = gh.FindPRsForCommit(ctx, owner, repo, event.CommitSHA)
680+
if err != nil {
681+
c.logger.Warn("Failed to look up PRs for commit",
682+
"commit_sha", event.CommitSHA,
683+
"owner", owner,
684+
"repo", repo,
685+
"error", err)
686+
// Don't cache errors - try again next time
687+
} else {
688+
// Cache the result (even if empty)
689+
c.cacheMu.Lock()
690+
if _, exists := c.commitPRCache[key]; !exists {
691+
c.commitCacheKeys = append(c.commitCacheKeys, key)
692+
// Evict oldest 25% if cache is full
693+
if len(c.commitCacheKeys) > c.maxCacheSize {
694+
n := c.maxCacheSize / 4
695+
for i := range n {
696+
delete(c.commitPRCache, c.commitCacheKeys[i])
697+
}
698+
c.commitCacheKeys = c.commitCacheKeys[n:]
699+
}
700+
}
701+
// Store copy to prevent external modifications
702+
cached := make([]int, len(prs))
703+
copy(cached, prs)
704+
c.commitPRCache[key] = cached
705+
c.cacheMu.Unlock()
706+
707+
c.logger.Info("Cached PR lookup result",
708+
"commit_sha", event.CommitSHA,
709+
"pr_count", len(prs))
710+
}
711+
}
712+
713+
// Emit events for each PR found
714+
if len(prs) > 0 {
715+
for _, n := range prs {
716+
e := event // Copy the event
717+
e.URL = fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, n)
718+
719+
if c.config.OnEvent != nil {
720+
c.logger.Info("Event received (expanded from commit)",
721+
"timestamp", e.Timestamp.Format("15:04:05"),
722+
"event_number", eventNum,
723+
"type", e.Type,
724+
"url", e.URL,
725+
"commit_sha", e.CommitSHA,
726+
"delivery_id", e.DeliveryID)
727+
c.config.OnEvent(e)
728+
}
729+
}
730+
continue // Skip the normal event handling since we expanded it
731+
}
732+
c.logger.Info("No PRs found for commit - may be push to main",
733+
"commit_sha", event.CommitSHA,
734+
"owner", owner,
735+
"repo", repo)
736+
}
737+
}
738+
628739
// Log event
629740
if c.config.Verbose {
630741
c.logger.Info("Event received",
631742
"event_number", eventNum,
632743
"timestamp", event.Timestamp.Format("15:04:05"),
633744
"type", event.Type,
634745
"url", event.URL,
746+
"commit_sha", event.CommitSHA,
635747
"delivery_id", event.DeliveryID,
636748
"raw", event.Raw)
637749
} else {
@@ -641,6 +753,7 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
641753
"event_number", eventNum,
642754
"type", event.Type,
643755
"url", event.URL,
756+
"commit_sha", event.CommitSHA,
644757
"delivery_id", event.DeliveryID)
645758
} else {
646759
c.logger.Info("Event received",

pkg/client/client_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
)
9+
10+
// TestStopMultipleCalls verifies that calling Stop() multiple times is safe
11+
// and doesn't panic with "close of closed channel".
12+
func TestStopMultipleCalls(t *testing.T) {
13+
// Create a client with minimal config
14+
client, err := New(Config{
15+
ServerURL: "ws://localhost:8080",
16+
Token: "test-token",
17+
Organization: "test-org",
18+
NoReconnect: true, // Disable reconnect to make test faster
19+
})
20+
if err != nil {
21+
t.Fatalf("Failed to create client: %v", err)
22+
}
23+
24+
// Start the client in a goroutine
25+
ctx, cancel := context.WithCancel(context.Background())
26+
defer cancel()
27+
28+
go func() {
29+
// 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+
}
33+
}()
34+
35+
// Give it a moment to initialize
36+
time.Sleep(10 * time.Millisecond)
37+
38+
// Call Stop() multiple times concurrently
39+
// This should NOT panic with "close of closed channel"
40+
var wg sync.WaitGroup
41+
for i := 0; i < 10; i++ {
42+
wg.Add(1)
43+
go func() {
44+
defer wg.Done()
45+
client.Stop() // Should be safe to call multiple times
46+
}()
47+
}
48+
49+
// Wait for all Stop() calls to complete
50+
wg.Wait()
51+
52+
// If we get here without a panic, the test passes
53+
}
54+
55+
// TestStopBeforeStart verifies that calling Stop() before Start() is safe.
56+
func TestStopBeforeStart(t *testing.T) {
57+
client, err := New(Config{
58+
ServerURL: "ws://localhost:8080",
59+
Token: "test-token",
60+
Organization: "test-org",
61+
NoReconnect: true,
62+
})
63+
if err != nil {
64+
t.Fatalf("Failed to create client: %v", err)
65+
}
66+
67+
// Call Stop() before Start()
68+
client.Stop()
69+
70+
// Now try to start - should exit cleanly
71+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
72+
defer cancel()
73+
74+
err = client.Start(ctx)
75+
// We expect either context.DeadlineExceeded or "stop requested"
76+
if err == nil {
77+
t.Error("Expected Start() to fail after Stop(), but it succeeded")
78+
}
79+
}

pkg/github/client.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,94 @@ func (c *Client) ValidateOrgMembership(ctx context.Context, org string) (usernam
463463
log.Printf("GitHub API: User is member of %d organizations: %v", len(orgNames), orgNames)
464464
return username, orgNames, errors.New("user is not a member of the requested organization")
465465
}
466+
467+
// FindPRsForCommit finds all pull requests associated with a specific commit SHA.
468+
// This is useful for resolving check_run/check_suite events when GitHub's pull_requests array is empty.
469+
// Returns a list of PR numbers that contain this commit.
470+
func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, commitSHA string) ([]int, error) {
471+
var prNumbers []int
472+
var lastErr error
473+
474+
// Use GitHub's API to list PRs associated with a commit
475+
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s/pulls", owner, repo, commitSHA)
476+
477+
err := retry.Do(
478+
func() error {
479+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
480+
if err != nil {
481+
return fmt.Errorf("failed to create request: %w", err)
482+
}
483+
484+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
485+
req.Header.Set("Accept", "application/vnd.github.v3+json")
486+
req.Header.Set("User-Agent", "webhook-sprinkler/1.0")
487+
488+
resp, err := c.httpClient.Do(req)
489+
if err != nil {
490+
lastErr = fmt.Errorf("failed to make request: %w", err)
491+
log.Printf("GitHub API request failed (will retry): %v", err)
492+
return err // Retry on network errors
493+
}
494+
defer func() {
495+
if err := resp.Body.Close(); err != nil {
496+
log.Printf("failed to close response body: %v", err)
497+
}
498+
}()
499+
500+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
501+
if err != nil {
502+
lastErr = fmt.Errorf("failed to read response: %w", err)
503+
return err // Retry on read errors
504+
}
505+
506+
// Handle status codes
507+
switch resp.StatusCode {
508+
case http.StatusOK:
509+
// Success - parse response
510+
var prs []struct {
511+
Number int `json:"number"`
512+
}
513+
if err := json.Unmarshal(body, &prs); err != nil {
514+
return retry.Unrecoverable(fmt.Errorf("failed to parse PR list response: %w", err))
515+
}
516+
517+
prNumbers = make([]int, len(prs))
518+
for i, pr := range prs {
519+
prNumbers[i] = pr.Number
520+
}
521+
return nil
522+
523+
case http.StatusNotFound:
524+
// Commit not found - could be a commit to main or repo doesn't exist
525+
return retry.Unrecoverable(fmt.Errorf("commit not found: %s", commitSHA))
526+
527+
case http.StatusUnauthorized, http.StatusForbidden:
528+
// Don't retry on auth errors
529+
return retry.Unrecoverable(fmt.Errorf("authentication failed: status %d", resp.StatusCode))
530+
531+
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable:
532+
// Retry on server errors
533+
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)
535+
return lastErr
536+
537+
default:
538+
// Don't retry on other errors
539+
return retry.Unrecoverable(fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(body)))
540+
}
541+
},
542+
retry.Attempts(3),
543+
retry.DelayType(retry.FullJitterBackoffDelay),
544+
retry.MaxDelay(2*time.Minute),
545+
retry.Context(ctx),
546+
)
547+
if err != nil {
548+
if lastErr != nil {
549+
return nil, lastErr
550+
}
551+
return nil, err
552+
}
553+
554+
log.Printf("GitHub API: Found %d PR(s) for commit %s in %s/%s", len(prNumbers), commitSHA, owner, repo)
555+
return prNumbers, nil
556+
}

pkg/srv/hub.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
// Event represents a GitHub webhook event that will be broadcast to clients.
1515
// It contains the PR URL, timestamp, event type, and delivery ID from GitHub.
1616
type Event struct {
17-
URL string `json:"url"` // Pull request URL
18-
Timestamp time.Time `json:"timestamp"` // When the event occurred
19-
Type string `json:"type"` // GitHub event type (e.g., "pull_request")
20-
DeliveryID string `json:"delivery_id,omitempty"` // GitHub webhook delivery ID (unique per webhook)
17+
URL string `json:"url"` // Pull request URL (or repo URL for check events with race condition)
18+
Timestamp time.Time `json:"timestamp"` // When the event occurred
19+
Type string `json:"type"` // GitHub event type (e.g., "pull_request")
20+
DeliveryID string `json:"delivery_id,omitempty"` // GitHub webhook delivery ID (unique per webhook)
21+
CommitSHA string `json:"commit_sha,omitempty"` // Commit SHA for check events (used to look up PR when URL is repo-only)
2122
}
2223

2324
// Hub manages WebSocket clients and event broadcasting.

pkg/webhook/handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
224224
DeliveryID: deliveryID,
225225
}
226226

227+
// For check events, include commit SHA to allow PR lookup when URL is repo-only (race condition)
228+
if eventType == "check_run" || eventType == "check_suite" {
229+
event.CommitSHA = extractCommitSHA(eventType, payload)
230+
}
231+
227232
// Get client count before broadcasting (for debugging delivery issues)
228233
clientCount := h.hub.ClientCount()
229234

0 commit comments

Comments
 (0)