55 "context"
66 "fmt"
77 "log/slog"
8+ "strings"
89 "time"
910
1011 "github.com/codeGROOVE-dev/slacker/internal/slack"
@@ -16,6 +17,187 @@ const (
1617 defaultReminderDMDelayMinutes = 65 // Default delay in minutes before sending DM if user tagged in channel
1718)
1819
20+ // UserMapper interface for formatting user mentions in messages.
21+ type UserMapper interface {
22+ FormatUserMentions (ctx context.Context , githubUsers []string , owner , domain string ) string
23+ }
24+
25+ // MessageParams contains all parameters needed to format a channel message.
26+ type MessageParams struct {
27+ CheckResult * turn.CheckResponse
28+ Owner string
29+ Repo string
30+ PRNumber int
31+ Title string
32+ Author string
33+ HTMLURL string
34+ Domain string
35+ UserMapper UserMapper
36+ }
37+
38+ // FormatChannelMessageBase formats the base Slack channel message for a PR (without next actions).
39+ // This is the single source of truth for channel message formatting.
40+ // It handles emoji determination and base message assembly.
41+ // Use FormatNextActionsSuffix to add next actions when needed.
42+ func FormatChannelMessageBase (ctx context.Context , params MessageParams ) string {
43+ pr := params .CheckResult .PullRequest
44+ a := params .CheckResult .Analysis
45+ prID := fmt .Sprintf ("%s/%s#%d" , params .Owner , params .Repo , params .PRNumber )
46+
47+ // Log all decision factors for debugging
48+ kinds := make ([]string , 0 , len (a .NextAction ))
49+ for user , action := range a .NextAction {
50+ kinds = append (kinds , fmt .Sprintf ("%s:%s" , user , action .Kind ))
51+ }
52+
53+ slog .Info ("formatting channel message" ,
54+ "pr" , prID ,
55+ "pr_state" , pr .State ,
56+ "merged" , pr .Merged ,
57+ "draft" , pr .Draft ,
58+ "workflow_state" , a .WorkflowState ,
59+ "next_action_count" , len (a .NextAction ),
60+ "next_actions" , kinds ,
61+ "checks_failing" , a .Checks .Failing ,
62+ "checks_pending" , a .Checks .Pending ,
63+ "checks_waiting" , a .Checks .Waiting ,
64+ "approved" , a .Approved ,
65+ "unresolved_comments" , a .UnresolvedComments )
66+
67+ // Determine emoji and state parameter
68+ var emoji , state string
69+
70+ // Handle merged/closed states first (most definitive)
71+ if pr .Merged {
72+ emoji , state = ":rocket:" , "?st=merged"
73+ slog .Info ("using :rocket: emoji - PR is merged" , "pr" , prID , "merged_at" , pr .MergedAt )
74+ } else if pr .State == "closed" {
75+ emoji , state = ":x:" , "?st=closed"
76+ slog .Info ("using :x: emoji - PR is closed but not merged" , "pr" , prID )
77+ } else if a .WorkflowState == "newly_published" {
78+ emoji , state = ":new:" , "?st=newly_published"
79+ slog .Info ("using :new: emoji - newly published PR" , "pr" , prID , "workflow_state" , a .WorkflowState )
80+ } else if len (a .NextAction ) > 0 {
81+ action := PrimaryAction (a .NextAction )
82+ emoji = PrefixForAction (action )
83+ state = stateParam (params .CheckResult )
84+ slog .Info ("using emoji from primary next_action" , "pr" , prID , "primary_action" , action , "emoji" , emoji , "state_param" , state )
85+ } else {
86+ emoji , state = fallbackEmoji (params .CheckResult )
87+ slog .Info ("using fallback emoji - no workflow_state or next_actions" , "pr" , prID , "emoji" , emoji , "state_param" , state , "fallback_reason" , "empty_workflow_state_and_next_actions" )
88+ }
89+
90+ return fmt .Sprintf ("%s %s <%s|%s#%d> · %s" ,
91+ emoji ,
92+ params .Title ,
93+ params .HTMLURL + state ,
94+ params .Repo ,
95+ params .PRNumber ,
96+ params .Author )
97+ }
98+
99+ // FormatNextActionsSuffix formats the next actions suffix for a channel message.
100+ // Returns empty string if no next actions are present.
101+ // Call this after FormatChannelMessageBase to build a complete message with user mentions.
102+ func FormatNextActionsSuffix (ctx context.Context , params MessageParams ) string {
103+ if params .CheckResult == nil || len (params .CheckResult .Analysis .NextAction ) == 0 {
104+ return ""
105+ }
106+
107+ actions := formatNextActionsInternal (ctx , params .CheckResult .Analysis .NextAction , params .Owner , params .Domain , params .UserMapper )
108+ if actions != "" {
109+ return fmt .Sprintf (" → %s" , actions )
110+ }
111+ return ""
112+ }
113+
114+ // stateParam returns the URL state parameter based on PR analysis.
115+ func stateParam (r * turn.CheckResponse ) string {
116+ pr := r .PullRequest
117+ a := r .Analysis
118+
119+ if pr .Draft {
120+ return "?st=tests_running"
121+ }
122+ if a .Checks .Failing > 0 {
123+ return "?st=tests_broken"
124+ }
125+ if a .Checks .Pending > 0 || a .Checks .Waiting > 0 {
126+ return "?st=tests_running"
127+ }
128+ if a .Approved {
129+ if a .UnresolvedComments > 0 {
130+ return "?st=changes_requested"
131+ }
132+ return "?st=approved"
133+ }
134+ return "?st=awaiting_review"
135+ }
136+
137+ // fallbackEmoji determines emoji when no workflow_state or next_actions are available.
138+ func fallbackEmoji (r * turn.CheckResponse ) (emoji , state string ) {
139+ pr := r .PullRequest
140+ a := r .Analysis
141+
142+ if pr .Draft {
143+ return ":test_tube:" , "?st=tests_running"
144+ }
145+ if a .Checks .Failing > 0 {
146+ return ":cockroach:" , "?st=tests_broken"
147+ }
148+ if a .Checks .Pending > 0 || a .Checks .Waiting > 0 {
149+ return ":test_tube:" , "?st=tests_running"
150+ }
151+ if a .Approved {
152+ if a .UnresolvedComments > 0 {
153+ return ":carpentry_saw:" , "?st=changes_requested"
154+ }
155+ return ":white_check_mark:" , "?st=approved"
156+ }
157+ return ":hourglass:" , "?st=awaiting_review"
158+ }
159+
160+ // formatNextActionsInternal formats next actions with user mentions (internal helper).
161+ func formatNextActionsInternal (ctx context.Context , nextActions map [string ]turn.Action , owner , domain string , userMapper UserMapper ) string {
162+ if len (nextActions ) == 0 {
163+ return ""
164+ }
165+
166+ // Group users by action kind, filtering out _system users
167+ actionGroups := make (map [string ][]string )
168+ for user , action := range nextActions {
169+ actionKind := string (action .Kind )
170+ // Skip _system users but still track the action exists
171+ if user != "_system" {
172+ actionGroups [actionKind ] = append (actionGroups [actionKind ], user )
173+ } else if _ , exists := actionGroups [actionKind ]; ! exists {
174+ // Action only has _system - create empty slice to track it exists
175+ actionGroups [actionKind ] = []string {}
176+ }
177+ }
178+
179+ // Format each action group
180+ var parts []string
181+ for actionKind , users := range actionGroups {
182+ // Convert snake_case to space-separated words
183+ actionName := strings .ReplaceAll (actionKind , "_" , " " )
184+
185+ // Format user mentions (will be empty if only _system was assigned)
186+ userMentions := userMapper .FormatUserMentions (ctx , users , owner , domain )
187+
188+ // If action has users, format as "action: users"
189+ // If no users (was only _system), just show the action
190+ if userMentions != "" {
191+ parts = append (parts , fmt .Sprintf ("%s: %s" , actionName , userMentions ))
192+ } else {
193+ parts = append (parts , actionName )
194+ }
195+ }
196+
197+ // Use semicolons to separate different actions (commas are used between users)
198+ return strings .Join (parts , "; " )
199+ }
200+
19201// Manager handles user notifications across multiple workspaces.
20202type Manager struct {
21203 slackManager * slack.Manager
0 commit comments