Skip to content

Commit 378d3c4

Browse files
authored
Merge pull request #56 from codeGROOVE-dev/reliable
Improve commit->PR lookup reliability
2 parents 8cdbb32 + 5cefdac commit 378d3c4

File tree

4 files changed

+222
-19
lines changed

4 files changed

+222
-19
lines changed

internal/bot/bot.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,8 +1132,6 @@ func (c *Coordinator) processPRForChannel(
11321132

11331133
// Resolve channel name to ID for API calls
11341134
channelID := c.slack.ResolveChannelID(ctx, channelName)
1135-
1136-
// Check if channel resolution failed
11371135
if channelID == channelName || (channelName != "" && channelName[0] == '#' && channelID == channelName[1:]) {
11381136
slog.Warn("could not resolve channel for PR processing",
11391137
"workspace", c.workspaceName,
@@ -1206,24 +1204,17 @@ func (c *Coordinator) processPRForChannel(
12061204
// Track that we notified users in this channel for DM delay logic
12071205
c.notifier.Tracker.UpdateChannelNotification(workspaceID, owner, repo, prNumber)
12081206

1209-
// Track user tags in channel asynchronously to avoid blocking thread creation
1210-
// This is the critical performance optimization - email lookups take 13-20 seconds each
1211-
// Extract GitHub usernames who are blocked on this PR
1207+
// Track user tags in channel for DM delay logic
12121208
blockedUsers := c.extractBlockedUsersFromTurnclient(checkResult)
12131209
domain := c.configManager.Domain(owner)
12141210
if len(blockedUsers) > 0 {
12151211
// Record tags for blocked users synchronously to prevent race with DM sending
1216-
// This must complete BEFORE DM notifications check tag info
1217-
// Note: Most lookups hit cache and are instant; occasional cold lookups may delay slightly
1218-
// but this is necessary for correct DM delay logic
12191212
lookupCtx, lookupCancel := context.WithTimeout(ctx, 5*time.Second)
12201213
defer lookupCancel()
12211214

12221215
for _, githubUser := range blockedUsers {
1223-
// Map GitHub username to Slack user ID
12241216
slackUserID, err := c.userMapper.SlackHandle(lookupCtx, githubUser, owner, domain)
12251217
if err == nil && slackUserID != "" {
1226-
// Track with channelID - this will only update on FIRST call per user/PR
12271218
c.notifier.Tracker.UpdateUserPRChannelTag(workspaceID, slackUserID, channelID, owner, repo, prNumber)
12281219
slog.Debug("tracked user tag in channel",
12291220
"workspace", workspaceID,

internal/bot/bot_sprinkler.go

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,88 @@ func eventKey(event client.Event) string {
4949
return fmt.Sprintf("%s:%s:%s", event.Timestamp.Format(time.RFC3339Nano), event.URL, event.Type)
5050
}
5151

52+
// lookupPRsForCheckEvent looks up PRs for a check event that only has a repo URL.
53+
// This happens when sprinkler's cache doesn't know about the PR yet (timing race).
54+
func (c *Coordinator) lookupPRsForCheckEvent(ctx context.Context, event client.Event, organization string) []int {
55+
commitSHA := ""
56+
deliveryID := ""
57+
if event.Raw != nil {
58+
if sha, ok := event.Raw["commit_sha"].(string); ok {
59+
commitSHA = sha
60+
}
61+
if id, ok := event.Raw["delivery_id"].(string); ok {
62+
deliveryID = id
63+
}
64+
}
65+
66+
if commitSHA == "" {
67+
slog.Warn("check event without PR number or commit SHA - cannot look up PRs",
68+
"organization", organization,
69+
"url", event.URL,
70+
"type", event.Type,
71+
"delivery_id", deliveryID)
72+
return nil
73+
}
74+
75+
// Extract owner/repo from URL
76+
parts := strings.Split(event.URL, "/")
77+
if len(parts) < 5 || parts[2] != "github.com" {
78+
slog.Error("could not extract repo from check event URL",
79+
"organization", organization,
80+
"url", event.URL,
81+
"error", "invalid URL format")
82+
return nil
83+
}
84+
owner := parts[3]
85+
repo := parts[4]
86+
87+
slog.Info("sprinkler cache miss - looking up PRs for commit via GitHub API",
88+
"organization", organization,
89+
"owner", owner,
90+
"repo", repo,
91+
"commit_sha", commitSHA,
92+
"type", event.Type,
93+
"delivery_id", deliveryID)
94+
95+
// Look up PRs for this commit using GitHub API
96+
// Allow up to 3 minutes for retries (2 min max delay + buffer)
97+
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
98+
defer cancel()
99+
100+
prNumbers, lookupErr := c.github.FindPRsForCommit(lookupCtx, owner, repo, commitSHA)
101+
if lookupErr != nil {
102+
slog.Error("failed to look up PRs for commit",
103+
"organization", organization,
104+
"owner", owner,
105+
"repo", repo,
106+
"commit_sha", commitSHA,
107+
"error", lookupErr,
108+
"impact", "will miss this check event",
109+
"fallback", "5-minute polling will catch it")
110+
return nil
111+
}
112+
113+
if len(prNumbers) == 0 {
114+
slog.Debug("no open PRs found for commit - likely push to main",
115+
"organization", organization,
116+
"owner", owner,
117+
"repo", repo,
118+
"commit_sha", commitSHA,
119+
"type", event.Type)
120+
return nil
121+
}
122+
123+
slog.Info("found PRs for commit - processing check event",
124+
"organization", organization,
125+
"owner", owner,
126+
"repo", repo,
127+
"commit_sha", commitSHA,
128+
"pr_count", len(prNumbers),
129+
"pr_numbers", prNumbers)
130+
131+
return prNumbers
132+
}
133+
52134
// handleSprinklerEvent processes a single event from sprinkler.
53135
func (c *Coordinator) handleSprinklerEvent(ctx context.Context, event client.Event, organization string) {
54136
// Generate event key using delivery_id if available, otherwise timestamp + URL + type.
@@ -130,10 +212,62 @@ func (c *Coordinator) handleSprinklerEvent(ctx context.Context, event client.Eve
130212

131213
prNumber, err := parsePRNumberFromURL(event.URL)
132214
if err != nil {
133-
slog.Error("failed to parse PR number from URL",
215+
// Sprinkler sends events with just repo URLs when it can't find PRs for a commit
216+
// For check events, try to recover by looking up PRs ourselves
217+
if event.Type == "check_suite" || event.Type == "check_run" {
218+
prNumbers := c.lookupPRsForCheckEvent(ctx, event, organization)
219+
if prNumbers == nil {
220+
return
221+
}
222+
223+
// Extract owner/repo from URL for constructing PR URLs
224+
parts := strings.Split(event.URL, "/")
225+
owner := parts[3]
226+
repo := parts[4]
227+
228+
// Process event for each PR found
229+
for _, num := range prNumbers {
230+
msg := SprinklerMessage{
231+
Type: event.Type,
232+
Event: event.Type,
233+
Repo: owner + "/" + repo,
234+
PRNumber: num,
235+
URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, num),
236+
Timestamp: event.Timestamp,
237+
}
238+
239+
// Process with timeout
240+
processCtx, processCancel := context.WithTimeout(ctx, 30*time.Second)
241+
if err := c.processEvent(processCtx, msg); err != nil {
242+
slog.Error("error processing recovered check event",
243+
"organization", organization,
244+
"error", err,
245+
"type", event.Type,
246+
"url", msg.URL,
247+
"repo", msg.Repo,
248+
"pr_number", num)
249+
}
250+
processCancel()
251+
}
252+
return
253+
}
254+
255+
// Non-check events without PR numbers - just log and ignore
256+
var commitSHA, deliveryID string
257+
if event.Raw != nil {
258+
if sha, ok := event.Raw["commit_sha"].(string); ok {
259+
commitSHA = sha
260+
}
261+
if id, ok := event.Raw["delivery_id"].(string); ok {
262+
deliveryID = id
263+
}
264+
}
265+
slog.Debug("ignoring event without PR number",
134266
"organization", organization,
135267
"url", event.URL,
136-
"error", err)
268+
"type", event.Type,
269+
"commit_sha", commitSHA,
270+
"delivery_id", deliveryID)
137271
return
138272
}
139273

internal/bot/polling_test.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import (
1515
// See: internal/bot/polling.go:98 - ListClosedPRs(ctx, org, 1)
1616
func TestClosedPRPollingWindow(t *testing.T) {
1717
tests := []struct {
18-
name string
19-
prClosedAt time.Time
20-
pollingRunsAt time.Time
21-
lookbackHours int
22-
expectPRIncluded bool
23-
scenario string
18+
name string
19+
prClosedAt time.Time
20+
pollingRunsAt time.Time
21+
lookbackHours int
22+
expectPRIncluded bool
23+
scenario string
2424
}{
2525
{
2626
name: "PR closed 5 minutes ago - should be caught",
@@ -210,6 +210,10 @@ func TestClosedPRRecoveryScenarios(t *testing.T) {
210210
// parseTime is a helper to create times on today's date for testing.
211211
func parseTime(hhMM string) time.Time {
212212
now := time.Now()
213-
parsed, _ := time.Parse("15:04", hhMM)
213+
parsed, err := time.Parse("15:04", hhMM)
214+
if err != nil {
215+
// This should never happen in tests with valid time strings
216+
panic("parseTime: invalid time format " + hhMM + ": " + err.Error())
217+
}
214218
return time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, now.Location())
215219
}

internal/github/github.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,80 @@ type PRInfo struct {
586586
Number int
587587
}
588588

589+
// FindPRsForCommit finds all open PRs associated with a commit SHA.
590+
func (c *Client) FindPRsForCommit(ctx context.Context, owner, repo, sha string) ([]int, error) {
591+
if owner == "" || repo == "" || sha == "" {
592+
return nil, fmt.Errorf("invalid parameters: owner=%q, repo=%q, sha=%q", owner, repo, sha)
593+
}
594+
595+
slog.Debug("looking up PRs for commit",
596+
"owner", owner,
597+
"repo", repo,
598+
"sha", sha)
599+
600+
var allPRs []*github.PullRequest
601+
err := retry.Do(
602+
func() error {
603+
var resp *github.Response
604+
var err error
605+
allPRs, resp, err = c.client.PullRequests.ListPullRequestsWithCommit(
606+
ctx,
607+
owner,
608+
repo,
609+
sha,
610+
&github.PullRequestListOptions{
611+
State: "all", // Include open, closed, and merged
612+
},
613+
)
614+
if err != nil {
615+
if resp != nil && resp.StatusCode == http.StatusNotFound {
616+
// Commit doesn't exist or no PRs found - don't retry
617+
return retry.Unrecoverable(err)
618+
}
619+
slog.Warn("failed to list PRs for commit, retrying",
620+
"owner", owner,
621+
"repo", repo,
622+
"sha", sha,
623+
"error", err)
624+
return err
625+
}
626+
return nil
627+
},
628+
retry.Attempts(5),
629+
retry.Delay(time.Second),
630+
retry.MaxDelay(2*time.Minute),
631+
retry.DelayType(retry.BackOffDelay),
632+
retry.MaxJitter(time.Second),
633+
retry.LastErrorOnly(true),
634+
retry.Context(ctx),
635+
)
636+
if err != nil {
637+
slog.Debug("no PRs found for commit",
638+
"owner", owner,
639+
"repo", repo,
640+
"sha", sha,
641+
"error", err)
642+
return []int{}, nil // Return empty list, not error
643+
}
644+
645+
// Extract PR numbers from open PRs only
646+
var prNumbers []int
647+
for _, pr := range allPRs {
648+
if pr.GetState() == "open" {
649+
prNumbers = append(prNumbers, pr.GetNumber())
650+
}
651+
}
652+
653+
slog.Debug("found PRs for commit",
654+
"owner", owner,
655+
"repo", repo,
656+
"sha", sha,
657+
"pr_count", len(prNumbers),
658+
"pr_numbers", prNumbers)
659+
660+
return prNumbers, nil
661+
}
662+
589663
// Client returns the underlying GitHub client.
590664
func (c *Client) Client() *github.Client {
591665
return c.client

0 commit comments

Comments
 (0)