Skip to content

Commit ca24da4

Browse files
authored
Merge pull request #89 from tstromberg/reliable
Add delayed posting ("when")
2 parents e7c7588 + fb89c72 commit ca24da4

File tree

10 files changed

+702
-14
lines changed

10 files changed

+702
-14
lines changed

pkg/bot/bot.go

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,78 @@ func (*Coordinator) extractBlockedUsersFromTurnclient(checkResult *turn.CheckRes
853853
return blockedUsers
854854
}
855855

856+
// shouldPostThread determines if a PR thread should be posted based on configured threshold.
857+
// Returns (shouldPost bool, reason string).
858+
func (c *Coordinator) shouldPostThread(
859+
checkResult *turn.CheckResponse,
860+
when string,
861+
) (bool, string) {
862+
if checkResult == nil {
863+
return false, "no_check_result"
864+
}
865+
866+
pr := checkResult.PullRequest
867+
analysis := checkResult.Analysis
868+
869+
// Terminal states ALWAYS post (ensure visibility)
870+
if pr.Merged {
871+
return true, "pr_merged"
872+
}
873+
if pr.State == "closed" {
874+
return true, "pr_closed"
875+
}
876+
877+
switch when {
878+
case "immediate":
879+
return true, "immediate_mode"
880+
881+
case "assigned":
882+
// Post when PR has assignees
883+
if len(pr.Assignees) > 0 {
884+
return true, fmt.Sprintf("has_%d_assignees", len(pr.Assignees))
885+
}
886+
return false, "no_assignees"
887+
888+
case "blocked":
889+
// Post when someone needs to take action
890+
blockedUsers := c.extractBlockedUsersFromTurnclient(checkResult)
891+
if len(blockedUsers) > 0 {
892+
return true, fmt.Sprintf("blocked_on_%d_users", len(blockedUsers))
893+
}
894+
return false, "not_blocked_yet"
895+
896+
case "passing":
897+
// Post when tests pass - use WorkflowState as primary signal
898+
switch analysis.WorkflowState {
899+
case string(turn.StateAssignedWaitingForReview),
900+
string(turn.StateReviewedNeedsRefinement),
901+
string(turn.StateRefinedWaitingForApproval),
902+
string(turn.StateApprovedWaitingForMerge):
903+
return true, fmt.Sprintf("workflow_state_%s", analysis.WorkflowState)
904+
905+
case string(turn.StateNewlyPublished),
906+
string(turn.StateInDraft),
907+
string(turn.StatePublishedWaitingForTests),
908+
string(turn.StateTestedWaitingForAssignment):
909+
return false, fmt.Sprintf("waiting_for_%s", analysis.WorkflowState)
910+
911+
default:
912+
// Fallback: check test status directly
913+
if analysis.Checks.Failing > 0 {
914+
return false, "tests_failing"
915+
}
916+
if analysis.Checks.Pending > 0 || analysis.Checks.Waiting > 0 {
917+
return false, "tests_pending"
918+
}
919+
return true, "tests_passed_fallback"
920+
}
921+
922+
default:
923+
slog.Warn("invalid when value, defaulting to immediate", "when", when)
924+
return true, "invalid_config_default_immediate"
925+
}
926+
}
927+
856928
// formatNextActions formats NextAction map into a compact string like "fix tests: user1, user2; review: user3".
857929
// It groups users by action kind and formats each action as "action_name: user1, user2".
858930
// Multiple actions are separated by semicolons.
@@ -1082,7 +1154,8 @@ func (c *Coordinator) processPRForChannel(
10821154
oldState = threadInfo.LastState
10831155
}
10841156

1085-
// Find or create thread
1157+
// Check if thread already exists
1158+
cacheKey := fmt.Sprintf("%s/%s#%d:%s", owner, repo, prNumber, channelID)
10861159
pullRequestStruct := struct {
10871160
CreatedAt time.Time `json:"created_at"`
10881161
User struct {
@@ -1098,6 +1171,36 @@ func (c *Coordinator) processPRForChannel(
10981171
Number: event.PullRequest.Number,
10991172
CreatedAt: event.PullRequest.CreatedAt,
11001173
}
1174+
1175+
_, _, threadExists := c.findPRThread(ctx, cacheKey, channelID, owner, repo, prNumber, prState, pullRequestStruct)
1176+
1177+
// If thread doesn't exist, check if we should create it based on "when" threshold
1178+
if !threadExists {
1179+
when := c.configManager.When(owner, channelName)
1180+
1181+
if when != "immediate" {
1182+
shouldPost, reason := c.shouldPostThread(checkResult, when)
1183+
1184+
if !shouldPost {
1185+
slog.Debug("not creating thread - threshold not met",
1186+
"workspace", c.workspaceName,
1187+
logFieldPR, fmt.Sprintf(prFormatString, owner, repo, prNumber),
1188+
"channel", channelDisplay,
1189+
"when", when,
1190+
"reason", reason)
1191+
return nil // Don't create thread yet - next event will check again
1192+
}
1193+
1194+
slog.Info("creating thread - threshold met",
1195+
"workspace", c.workspaceName,
1196+
logFieldPR, fmt.Sprintf(prFormatString, owner, repo, prNumber),
1197+
"channel", channelDisplay,
1198+
"when", when,
1199+
"reason", reason)
1200+
}
1201+
}
1202+
1203+
// Find or create thread
11011204
threadTS, wasNewlyCreated, currentText, err := c.findOrCreatePRThread(ctx, threadCreationParams{
11021205
ChannelID: channelID,
11031206
ChannelName: channelName,

pkg/bot/bot_test.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"os"
77
"testing"
88

9+
"github.com/codeGROOVE-dev/prx/pkg/prx"
910
"github.com/codeGROOVE-dev/slacker/pkg/bot/cache"
11+
"github.com/codeGROOVE-dev/turnclient/pkg/turn"
1012
"github.com/slack-go/slack"
1113
)
1214

@@ -309,3 +311,234 @@ func TestThreadCache_Set(t *testing.T) {
309311
t.Errorf("expected channel ID %s, got %s", threadInfo.ChannelID, retrieved.ChannelID)
310312
}
311313
}
314+
315+
func TestShouldPostThread(t *testing.T) {
316+
coord := &Coordinator{}
317+
318+
tests := []struct {
319+
name string
320+
when string
321+
checkResult *turn.CheckResponse
322+
wantPost bool
323+
wantReasonPart string // Part of the reason string to check for
324+
}{
325+
{
326+
name: "immediate mode always posts",
327+
when: "immediate",
328+
checkResult: &turn.CheckResponse{
329+
PullRequest: prx.PullRequest{
330+
State: "open",
331+
},
332+
},
333+
wantPost: true,
334+
wantReasonPart: "immediate_mode",
335+
},
336+
{
337+
name: "merged PR always posts regardless of when",
338+
when: "passing",
339+
checkResult: &turn.CheckResponse{
340+
PullRequest: prx.PullRequest{
341+
State: "closed",
342+
Merged: true,
343+
},
344+
},
345+
wantPost: true,
346+
wantReasonPart: "pr_merged",
347+
},
348+
{
349+
name: "closed PR always posts regardless of when",
350+
when: "passing",
351+
checkResult: &turn.CheckResponse{
352+
PullRequest: prx.PullRequest{
353+
State: "closed",
354+
Merged: false,
355+
},
356+
},
357+
wantPost: true,
358+
wantReasonPart: "pr_closed",
359+
},
360+
{
361+
name: "assigned: posts when has assignees",
362+
when: "assigned",
363+
checkResult: &turn.CheckResponse{
364+
PullRequest: prx.PullRequest{
365+
State: "open",
366+
Assignees: []string{"user1", "user2"},
367+
},
368+
},
369+
wantPost: true,
370+
wantReasonPart: "has_2_assignees",
371+
},
372+
{
373+
name: "assigned: does not post when no assignees",
374+
when: "assigned",
375+
checkResult: &turn.CheckResponse{
376+
PullRequest: prx.PullRequest{
377+
State: "open",
378+
Assignees: []string{},
379+
},
380+
},
381+
wantPost: false,
382+
wantReasonPart: "no_assignees",
383+
},
384+
{
385+
name: "blocked: posts when users are blocked",
386+
when: "blocked",
387+
checkResult: &turn.CheckResponse{
388+
PullRequest: prx.PullRequest{
389+
State: "open",
390+
},
391+
Analysis: turn.Analysis{
392+
NextAction: map[string]turn.Action{
393+
"user1": {Kind: "review"},
394+
"user2": {Kind: "approve"},
395+
},
396+
},
397+
},
398+
wantPost: true,
399+
wantReasonPart: "blocked_on_2_users",
400+
},
401+
{
402+
name: "blocked: does not post when no users blocked",
403+
when: "blocked",
404+
checkResult: &turn.CheckResponse{
405+
PullRequest: prx.PullRequest{
406+
State: "open",
407+
},
408+
Analysis: turn.Analysis{
409+
NextAction: map[string]turn.Action{},
410+
},
411+
},
412+
wantPost: false,
413+
wantReasonPart: "not_blocked_yet",
414+
},
415+
{
416+
name: "blocked: ignores _system sentinel",
417+
when: "blocked",
418+
checkResult: &turn.CheckResponse{
419+
PullRequest: prx.PullRequest{
420+
State: "open",
421+
},
422+
Analysis: turn.Analysis{
423+
NextAction: map[string]turn.Action{
424+
"_system": {Kind: "processing"},
425+
},
426+
},
427+
},
428+
wantPost: false,
429+
wantReasonPart: "not_blocked_yet",
430+
},
431+
{
432+
name: "passing: posts when in review state",
433+
when: "passing",
434+
checkResult: &turn.CheckResponse{
435+
PullRequest: prx.PullRequest{
436+
State: "open",
437+
},
438+
Analysis: turn.Analysis{
439+
WorkflowState: string(turn.StateAssignedWaitingForReview),
440+
},
441+
},
442+
wantPost: true,
443+
wantReasonPart: "workflow_state",
444+
},
445+
{
446+
name: "passing: does not post when tests pending",
447+
when: "passing",
448+
checkResult: &turn.CheckResponse{
449+
PullRequest: prx.PullRequest{
450+
State: "open",
451+
},
452+
Analysis: turn.Analysis{
453+
WorkflowState: string(turn.StatePublishedWaitingForTests),
454+
},
455+
},
456+
wantPost: false,
457+
wantReasonPart: "waiting_for",
458+
},
459+
{
460+
name: "passing: uses fallback when workflow state unknown and tests passing",
461+
when: "passing",
462+
checkResult: &turn.CheckResponse{
463+
PullRequest: prx.PullRequest{
464+
State: "open",
465+
},
466+
Analysis: turn.Analysis{
467+
WorkflowState: "unknown_state",
468+
Checks: turn.Checks{
469+
Passing: 5,
470+
Failing: 0,
471+
Pending: 0,
472+
Waiting: 0,
473+
},
474+
},
475+
},
476+
wantPost: true,
477+
wantReasonPart: "tests_passed_fallback",
478+
},
479+
{
480+
name: "passing: uses fallback when tests failing",
481+
when: "passing",
482+
checkResult: &turn.CheckResponse{
483+
PullRequest: prx.PullRequest{
484+
State: "open",
485+
},
486+
Analysis: turn.Analysis{
487+
WorkflowState: "unknown_state",
488+
Checks: turn.Checks{
489+
Passing: 3,
490+
Failing: 2,
491+
},
492+
},
493+
},
494+
wantPost: false,
495+
wantReasonPart: "tests_failing",
496+
},
497+
{
498+
name: "nil check result returns false",
499+
when: "passing",
500+
checkResult: nil,
501+
wantPost: false,
502+
wantReasonPart: "no_check_result",
503+
},
504+
{
505+
name: "invalid when value defaults to immediate",
506+
when: "invalid_value",
507+
checkResult: &turn.CheckResponse{
508+
PullRequest: prx.PullRequest{
509+
State: "open",
510+
},
511+
},
512+
wantPost: true,
513+
wantReasonPart: "invalid_config",
514+
},
515+
}
516+
517+
for _, tt := range tests {
518+
t.Run(tt.name, func(t *testing.T) {
519+
gotPost, gotReason := coord.shouldPostThread(tt.checkResult, tt.when)
520+
521+
if gotPost != tt.wantPost {
522+
t.Errorf("shouldPostThread() gotPost = %v, wantPost %v", gotPost, tt.wantPost)
523+
}
524+
525+
if tt.wantReasonPart != "" && !contains(gotReason, tt.wantReasonPart) {
526+
t.Errorf("shouldPostThread() reason = %q, want to contain %q", gotReason, tt.wantReasonPart)
527+
}
528+
})
529+
}
530+
}
531+
532+
// Helper function to check if a string contains a substring
533+
func contains(s, substr string) bool {
534+
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
535+
}
536+
537+
func containsMiddle(s, substr string) bool {
538+
for i := 0; i <= len(s)-len(substr); i++ {
539+
if s[i:i+len(substr)] == substr {
540+
return true
541+
}
542+
}
543+
return false
544+
}

pkg/bot/integration_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,10 @@ func (m *mockConfigManager) ReminderDMDelay(org, channel string) int {
386386
return m.dmDelay
387387
}
388388

389+
func (m *mockConfigManager) When(org, channel string) string {
390+
return "immediate" // Default for tests
391+
}
392+
389393
func (m *mockConfigManager) ChannelsForRepo(org, repo string) []string {
390394
if m.channelsFunc != nil {
391395
return m.channelsFunc(org, repo)

pkg/bot/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type ConfigManager interface {
5454
WorkspaceName(org string) string
5555
ChannelsForRepo(org, repo string) []string
5656
ReminderDMDelay(org, channelName string) int
57+
When(org, channelName string) string
5758
SetGitHubClient(org string, client any)
5859
SetWorkspaceName(workspaceName string)
5960
}

0 commit comments

Comments
 (0)