@@ -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.
@@ -85,9 +87,16 @@ type Client struct {
8587 ws * websocket.Conn
8688 stopCh chan struct {}
8789 stoppedCh chan struct {}
88- writeCh chan any // Channel for serializing all writes
90+ stopOnce sync.Once // Ensures Stop() is only executed once
91+ writeCh chan any // Channel for serializing all writes
8992 eventCount int
9093 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
91100}
92101
93102// New creates a new robust WebSocket client.
@@ -118,10 +127,13 @@ func New(config Config) (*Client, error) {
118127 }
119128
120129 return & Client {
121- config : config ,
122- stopCh : make (chan struct {}),
123- stoppedCh : make (chan struct {}),
124- 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 ,
125137 }, nil
126138}
127139
@@ -220,16 +232,27 @@ func (c *Client) Start(ctx context.Context) error {
220232}
221233
222234// Stop gracefully stops the client.
235+ // Safe to call multiple times - only the first call will take effect.
236+ // Also safe to call before Start() or if Start() was never called.
223237func (c * Client ) Stop () {
224- close (c .stopCh )
225- c .mu .Lock ()
226- if c .ws != nil {
227- if closeErr := c .ws .Close (); closeErr != nil {
228- c .logger .Error ("Error closing websocket on shutdown" , "error" , closeErr )
238+ c .stopOnce .Do (func () {
239+ close (c .stopCh )
240+ c .mu .Lock ()
241+ if c .ws != nil {
242+ if closeErr := c .ws .Close (); closeErr != nil {
243+ c .logger .Error ("Error closing websocket on shutdown" , "error" , closeErr )
244+ }
229245 }
230- }
231- c .mu .Unlock ()
232- <- c .stoppedCh
246+ c .mu .Unlock ()
247+
248+ // Wait for Start() to finish, but with timeout in case Start() was never called
249+ select {
250+ case <- c .stoppedCh :
251+ // Start() completed normally
252+ case <- time .After (100 * time .Millisecond ):
253+ // Start() was never called or hasn't started yet - that's ok
254+ }
255+ })
233256}
234257
235258// connect establishes a WebSocket connection and handles events.
@@ -608,18 +631,119 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
608631 event .DeliveryID = deliveryID
609632 }
610633
634+ if commitSHA , ok := response ["commit_sha" ].(string ); ok {
635+ event .CommitSHA = commitSHA
636+ }
637+
611638 c .mu .Lock ()
612639 c .eventCount ++
613640 eventNum := c .eventCount
614641 c .mu .Unlock ()
615642
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+
616739 // Log event
617740 if c .config .Verbose {
618741 c .logger .Info ("Event received" ,
619742 "event_number" , eventNum ,
620743 "timestamp" , event .Timestamp .Format ("15:04:05" ),
621744 "type" , event .Type ,
622745 "url" , event .URL ,
746+ "commit_sha" , event .CommitSHA ,
623747 "delivery_id" , event .DeliveryID ,
624748 "raw" , event .Raw )
625749 } else {
@@ -629,6 +753,7 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
629753 "event_number" , eventNum ,
630754 "type" , event .Type ,
631755 "url" , event .URL ,
756+ "commit_sha" , event .CommitSHA ,
632757 "delivery_id" , event .DeliveryID )
633758 } else {
634759 c .logger .Info ("Event received" ,
0 commit comments