@@ -793,6 +793,86 @@ func (*Coordinator) extractBlockedUsersFromTurnclient(checkResult *turn.CheckRes
793793 return blockedUsers
794794}
795795
796+ // updateDMMessagesForPR updates DM messages for all blocked users on a PR.
797+ func (c * Coordinator ) updateDMMessagesForPR (ctx context.Context , checkResult * turn.CheckResponse , owner , repo string , prNumber int , title , author , prState , prURL string ) {
798+ if checkResult == nil || len (checkResult .Analysis .NextAction ) == 0 {
799+ slog .Debug ("no blocked users, skipping DM updates" ,
800+ "pr" , fmt .Sprintf ("%s/%s#%d" , owner , repo , prNumber ))
801+ return
802+ }
803+
804+ // Format the DM message (same format as initial send)
805+ prefix := notify .PrefixForState (prState )
806+ var action string
807+ switch prState {
808+ case "tests_broken" :
809+ action = "fix tests"
810+ case "awaiting_review" :
811+ action = "review"
812+ case "changes_requested" :
813+ action = "address feedback"
814+ case "approved" :
815+ action = "merge"
816+ default :
817+ action = "attention needed"
818+ }
819+
820+ message := fmt .Sprintf (
821+ "%s %s <%s|%s#%d> · %s → %s" ,
822+ prefix ,
823+ title ,
824+ prURL ,
825+ repo ,
826+ prNumber ,
827+ author ,
828+ action ,
829+ )
830+
831+ // Update DM for each blocked user
832+ updatedCount := 0
833+ skippedCount := 0
834+ domain := c .configManager .Domain (owner )
835+
836+ for githubUser := range checkResult .Analysis .NextAction {
837+ // Skip _system user
838+ if githubUser == "_system" {
839+ continue
840+ }
841+
842+ // Map GitHub user to Slack user
843+ slackUserID , err := c .userMapper .SlackHandle (ctx , githubUser , owner , domain )
844+ if err != nil || slackUserID == "" {
845+ slog .Debug ("no Slack mapping for GitHub user, skipping DM update" ,
846+ "github_user" , githubUser ,
847+ "pr" , fmt .Sprintf ("%s/%s#%d" , owner , repo , prNumber ),
848+ "error" , err )
849+ skippedCount ++
850+ continue
851+ }
852+
853+ // Update the DM message
854+ if err := c .slack .UpdateDMMessage (ctx , slackUserID , prURL , message ); err != nil {
855+ slog .Debug ("failed to update DM message" ,
856+ "user" , slackUserID ,
857+ "github_user" , githubUser ,
858+ "pr" , fmt .Sprintf ("%s/%s#%d" , owner , repo , prNumber ),
859+ "error" , err ,
860+ "reason" , "DM may not exist or too old" )
861+ skippedCount ++
862+ } else {
863+ updatedCount ++
864+ }
865+ }
866+
867+ if updatedCount > 0 {
868+ slog .Info ("updated DM messages for PR state change" ,
869+ "pr" , fmt .Sprintf ("%s/%s#%d" , owner , repo , prNumber ),
870+ "pr_state" , prState ,
871+ "updated" , updatedCount ,
872+ "skipped" , skippedCount )
873+ }
874+ }
875+
796876// formatNextActions formats NextAction map into a compact string like "fix tests: user1, user2; review: user3".
797877// It groups users by action kind and formats each action as "action_name: user1, user2".
798878// Multiple actions are separated by semicolons.
@@ -888,10 +968,23 @@ func (c *Coordinator) processChannelsInParallel(
888968 var validChannels []string
889969 for _ , channelName := range channels {
890970 channelID := c .slack .ResolveChannelID (ctx , channelName )
971+
972+ // Check if channel resolution failed (returns original name if not found)
973+ if channelID == channelName || (channelName != "" && channelName [0 ] == '#' && channelID == channelName [1 :]) {
974+ slog .Warn ("could not resolve channel - may not exist or bot lacks permissions" ,
975+ "workspace" , c .workspaceName ,
976+ logFieldPR , fmt .Sprintf (prFormatString , prCtx .Owner , prCtx .Repo , prCtx .Number ),
977+ "channel" , channelName ,
978+ "action_required" , "verify channel exists and bot has access" )
979+ continue
980+ }
981+
891982 if c .slack .IsBotInChannel (ctx , channelID ) {
892983 validChannels = append (validChannels , channelName )
893984 } else {
894985 slog .Warn ("skipping channel - bot not a member" ,
986+ "workspace" , c .workspaceName ,
987+ logFieldPR , fmt .Sprintf (prFormatString , prCtx .Owner , prCtx .Repo , prCtx .Number ),
895988 "channel" , channelName ,
896989 "channel_id" , channelID ,
897990 "action_required" , "invite bot to channel" )
@@ -960,6 +1053,16 @@ func (c *Coordinator) processPRForChannel(
9601053 // Resolve channel name to ID for API calls
9611054 channelID := c .slack .ResolveChannelID (ctx , channelName )
9621055
1056+ // Check if channel resolution failed
1057+ if channelID == channelName || (channelName != "" && channelName [0 ] == '#' && channelID == channelName [1 :]) {
1058+ slog .Warn ("could not resolve channel for PR processing" ,
1059+ "workspace" , c .workspaceName ,
1060+ logFieldPR , fmt .Sprintf (prFormatString , owner , repo , prNumber ),
1061+ "channel" , channelName ,
1062+ "action_required" , "verify channel exists and bot has access" )
1063+ return
1064+ }
1065+
9631066 // For display purposes, show both name and ID
9641067 var channelDisplay string
9651068 switch {
@@ -1025,30 +1128,28 @@ func (c *Coordinator) processPRForChannel(
10251128 blockedUsers := c .extractBlockedUsersFromTurnclient (checkResult )
10261129 domain := c .configManager .Domain (owner )
10271130 if len (blockedUsers ) > 0 {
1028- // Run email lookups in background to avoid blocking message delivery
1029- // SECURITY NOTE: Use detached context to complete email lookups even during shutdown.
1030- // Operations bounded by 15-second timeout. This ensures reasonably fast shutdown while
1031- // completing active lookups for accurate DM delivery (most lookups hit cache instantly,
1032- // but occasional cold lookups can take 10+ seconds).
1033- lookupCtx , lookupCancel := context .WithTimeout (context .WithoutCancel (ctx ), 15 * time .Second )
1034- go func () {
1035- defer lookupCancel ()
1036- for _ , githubUser := range blockedUsers {
1037- // Map GitHub username to Slack user ID
1038- slackUserID , err := c .userMapper .SlackHandle (lookupCtx , githubUser , owner , domain )
1039- if err == nil && slackUserID != "" {
1040- // Track with channelID - this will only update on FIRST call per user/PR
1041- c .notifier .Tracker .UpdateUserPRChannelTag (workspaceID , slackUserID , channelID , owner , repo , prNumber )
1042- slog .Debug ("tracked user tag in channel (async)" ,
1043- "workspace" , workspaceID ,
1044- "github_user" , githubUser ,
1045- "slack_user" , slackUserID ,
1046- "channel" , channelDisplay ,
1047- "channel_id" , channelID ,
1048- "pr" , fmt .Sprintf (prFormatString , owner , repo , prNumber ))
1049- }
1131+ // Record tags for blocked users synchronously to prevent race with DM sending
1132+ // This must complete BEFORE DM notifications check tag info
1133+ // Note: Most lookups hit cache and are instant; occasional cold lookups may delay slightly
1134+ // but this is necessary for correct DM delay logic
1135+ lookupCtx , lookupCancel := context .WithTimeout (ctx , 5 * time .Second )
1136+ defer lookupCancel ()
1137+
1138+ for _ , githubUser := range blockedUsers {
1139+ // Map GitHub username to Slack user ID
1140+ slackUserID , err := c .userMapper .SlackHandle (lookupCtx , githubUser , owner , domain )
1141+ if err == nil && slackUserID != "" {
1142+ // Track with channelID - this will only update on FIRST call per user/PR
1143+ c .notifier .Tracker .UpdateUserPRChannelTag (workspaceID , slackUserID , channelID , owner , repo , prNumber )
1144+ slog .Debug ("tracked user tag in channel" ,
1145+ "workspace" , workspaceID ,
1146+ "github_user" , githubUser ,
1147+ "slack_user" , slackUserID ,
1148+ "channel" , channelDisplay ,
1149+ "channel_id" , channelID ,
1150+ "pr" , fmt .Sprintf (prFormatString , owner , repo , prNumber ))
10501151 }
1051- }()
1152+ }
10521153 }
10531154
10541155 // Build what the message SHOULD be based on current PR state
@@ -1109,6 +1210,9 @@ func (c *Coordinator) processPRForChannel(
11091210 "channel_id" , channelID ,
11101211 "thread_ts" , threadTS ,
11111212 "pr_state" , prState )
1213+
1214+ // Also update DM messages for blocked users
1215+ c .updateDMMessagesForPR (ctx , checkResult , owner , repo , prNumber , event .PullRequest .Title , event .PullRequest .User .Login , prState , event .PullRequest .HTMLURL )
11121216 }
11131217 } else {
11141218 slog .Debug ("message already matches expected content, no update needed" ,
@@ -1291,10 +1395,21 @@ func (c *Coordinator) createPRThread(ctx context.Context, channel, owner, repo s
12911395
12921396 // Resolve channel name to ID for consistent API calls
12931397 resolvedChannel := c .slack .ResolveChannelID (ctx , channel )
1294- if resolvedChannel != channel {
1295- slog .Debug ("resolved channel for thread creation" , "original" , channel , "resolved" , resolvedChannel )
1398+
1399+ // Check if channel resolution failed (returns original name if not found)
1400+ if resolvedChannel == channel || (channel != "" && channel [0 ] == '#' && resolvedChannel == channel [1 :]) {
1401+ // Only warn if it's not already a channel ID
1402+ if channel == "" || channel [0 ] != 'C' {
1403+ slog .Warn ("could not resolve channel for thread creation" ,
1404+ "workspace" , c .workspaceName ,
1405+ logFieldPR , fmt .Sprintf (prFormatString , owner , repo , number ),
1406+ "channel" , channel ,
1407+ "action_required" , "verify channel exists and bot has access" )
1408+ return "" , "" , fmt .Errorf ("could not resolve channel: %s" , channel )
1409+ }
1410+ slog .Debug ("channel is already a channel ID" , "channel_id" , channel )
12961411 } else {
1297- slog .Debug ("channel resolution did not change value " , "channel " , channel , "might_be_channel_id_already " , resolvedChannel [ 0 ] == 'C' )
1412+ slog .Debug ("resolved channel for thread creation " , "original " , channel , "resolved " , resolvedChannel )
12981413 }
12991414
13001415 // Create thread with resolved channel ID - post immediately without waiting for user lookups
0 commit comments