44import logging
55import os
66import re
7- from typing import Any , Dict , Optional
7+ import threading
8+ from typing import Any , Dict , List , Optional
89
910from slack_bolt .adapter .socket_mode .async_handler import AsyncSocketModeHandler
1011from slack_bolt .async_app import AsyncApp
1112from slack_bolt .context .say .async_say import AsyncSay
1213
14+ from redis_release .bht .tree import async_tick_tock , initialize_tree_and_state
1315from redis_release .config import Config , load_config
1416from redis_release .models import ReleaseArgs
1517from redis_release .state_manager import S3StateStorage , StateManager
16- from redis_release .state_slack import SlackStatePrinter
18+ from redis_release .state_slack import init_slack_printer
1719
1820logger = logging .getLogger (__name__ )
1921
@@ -31,6 +33,7 @@ def __init__(
3133 slack_app_token : Optional [str ] = None ,
3234 reply_in_thread : bool = True ,
3335 broadcast_to_channel : bool = False ,
36+ authorized_users : Optional [List [str ]] = None ,
3437 ):
3538 """Initialize the bot.
3639
@@ -40,10 +43,12 @@ def __init__(
4043 slack_app_token: Slack app token (xapp-...). If None, uses SLACK_APP_TOKEN env var
4144 reply_in_thread: If True, reply in thread. If False, reply in main channel
4245 broadcast_to_channel: If True and reply_in_thread is True, also show in main channel
46+ authorized_users: List of user IDs authorized to run releases. If None, all users are authorized
4347 """
4448 self .config = config
4549 self .reply_in_thread = reply_in_thread
4650 self .broadcast_to_channel = broadcast_to_channel
51+ self .authorized_users = authorized_users or []
4752
4853 # Get tokens from args or environment
4954 bot_token = slack_bot_token or os .environ .get ("SLACK_BOT_TOKEN" )
@@ -75,7 +80,7 @@ def _register_handlers(self) -> None:
7580 async def handle_app_mention ( # pyright: ignore[reportUnusedFunction]
7681 event : Dict [str , Any ], say : AsyncSay , logger : logging .Logger
7782 ) -> None :
78- """Handle app mentions and check for status requests."""
83+ """Handle app mentions and check for status/release requests."""
7984 try :
8085 text = event .get ("text" , "" ).lower ()
8186 channel = event .get ("channel" )
@@ -96,9 +101,18 @@ async def handle_app_mention( # pyright: ignore[reportUnusedFunction]
96101 f"Received mention from user { user } in channel { channel } : { text } "
97102 )
98103
104+ # Check if message contains "release" command
105+ if "release" in text :
106+ await self ._handle_release_command (
107+ event .get ("text" , "" ), channel , user , thread_ts , logger
108+ )
109+ return
110+
99111 # Check if message contains "status"
100112 if "status" not in text :
101- logger .debug ("Message doesn't contain 'status', ignoring" )
113+ logger .debug (
114+ "Message doesn't contain 'status' or 'release', ignoring"
115+ )
102116 return
103117
104118 # Extract version tag from message
@@ -152,6 +166,142 @@ def _extract_version_tag(self, text: str) -> Optional[str]:
152166 return match .group (1 )
153167 return None
154168
169+ async def _handle_release_command (
170+ self ,
171+ text : str ,
172+ channel : str ,
173+ user : str ,
174+ thread_ts : str ,
175+ logger : logging .Logger ,
176+ ) -> None :
177+ """Handle release command to start a new release.
178+
179+ Args:
180+ text: Message text
181+ channel: Slack channel ID
182+ user: User ID who requested the release
183+ thread_ts: Thread timestamp to reply in
184+ logger: Logger instance
185+ """
186+ # Check authorization
187+ if self .authorized_users and user not in self .authorized_users :
188+ logger .warning (
189+ f"Unauthorized release attempt by user { user } . Authorized users: { self .authorized_users } "
190+ )
191+ if self .reply_in_thread :
192+ await self .app .client .chat_postMessage (
193+ channel = channel ,
194+ thread_ts = thread_ts ,
195+ text = f"<@{ user } > Sorry, you are not authorized to run releases. "
196+ "Please contact an administrator." ,
197+ )
198+ else :
199+ await self .app .client .chat_postMessage (
200+ channel = channel ,
201+ text = f"<@{ user } > Sorry, you are not authorized to run releases. "
202+ "Please contact an administrator." ,
203+ )
204+ return
205+
206+ # Extract version tag from message
207+ tag = self ._extract_version_tag (text )
208+
209+ if not tag :
210+ if self .reply_in_thread :
211+ await self .app .client .chat_postMessage (
212+ channel = channel ,
213+ thread_ts = thread_ts ,
214+ text = f"<@{ user } > I couldn't find a version tag in your message. "
215+ "Please mention me with 'release' and a version tag like `8.4-m01` or `7.2.5`." ,
216+ )
217+ else :
218+ await self .app .client .chat_postMessage (
219+ channel = channel ,
220+ text = f"<@{ user } > I couldn't find a version tag in your message. "
221+ "Please mention me with 'release' and a version tag like `8.4-m01` or `7.2.5`." ,
222+ )
223+ return
224+
225+ logger .info (f"Processing release command for tag: { tag } " )
226+
227+ # Check if message contains "broadcast" keyword
228+ broadcast_requested = "broadcast" in text .lower ()
229+ reply_broadcast = self .broadcast_to_channel or broadcast_requested
230+
231+ if broadcast_requested :
232+ logger .info ("Broadcast mode enabled via 'broadcast' keyword in message" )
233+
234+ # Acknowledge the command
235+ if self .reply_in_thread :
236+ await self .app .client .chat_postMessage (
237+ channel = channel ,
238+ thread_ts = thread_ts ,
239+ text = f"<@{ user } > Starting release for tag `{ tag } `... I'll post updates in this thread."
240+ + (" (broadcasting to channel)" if reply_broadcast else "" ),
241+ )
242+ else :
243+ await self .app .client .chat_postMessage (
244+ channel = channel ,
245+ text = f"<@{ user } > Starting release for tag `{ tag } `..." ,
246+ )
247+
248+ # Start release in a separate thread
249+ def run_release_in_thread () -> None :
250+ """Run release in a separate thread with its own event loop."""
251+ # Create new event loop for this thread
252+ loop = asyncio .new_event_loop ()
253+ asyncio .set_event_loop (loop )
254+
255+ try :
256+ # Create release args
257+ args = ReleaseArgs (
258+ release_tag = tag ,
259+ force_rebuild = [],
260+ slack_token = self .bot_token ,
261+ slack_channel_id = channel ,
262+ slack_thread_ts = thread_ts if self .reply_in_thread else None ,
263+ slack_reply_broadcast = reply_broadcast ,
264+ )
265+
266+ # Run the release
267+ with initialize_tree_and_state (self .config , args ) as (tree , _ ):
268+ loop .run_until_complete (async_tick_tock (tree , cutoff = 2000 ))
269+
270+ logger .info (f"Release { tag } completed" )
271+
272+ except Exception as e :
273+ logger .error (f"Error running release { tag } : { e } " , exc_info = True )
274+ # Post error message to Slack
275+ try :
276+ if self .reply_in_thread :
277+ loop .run_until_complete (
278+ self .app .client .chat_postMessage (
279+ channel = channel ,
280+ thread_ts = thread_ts ,
281+ text = f"<@{ user } > Release `{ tag } ` failed with error: { str (e )} " ,
282+ )
283+ )
284+ else :
285+ loop .run_until_complete (
286+ self .app .client .chat_postMessage (
287+ channel = channel ,
288+ text = f"<@{ user } > Release `{ tag } ` failed with error: { str (e )} " ,
289+ )
290+ )
291+ except Exception as slack_error :
292+ logger .error (
293+ f"Failed to post error to Slack: { slack_error } " , exc_info = True
294+ )
295+ finally :
296+ loop .close ()
297+
298+ # Start the thread
299+ release_thread = threading .Thread (
300+ target = run_release_in_thread , name = f"release-{ tag } " , daemon = True
301+ )
302+ release_thread .start ()
303+ logger .info (f"Started release thread for tag { tag } " )
304+
155305 async def _post_status (
156306 self , tag : str , channel : str , user : str , thread_ts : str
157307 ) -> None :
@@ -199,25 +349,14 @@ async def _post_status(
199349 )
200350 return
201351
202- # Get status blocks from SlackStatePrinter
203- printer = SlackStatePrinter (self .bot_token , channel )
204- blocks = printer ._make_blocks (state )
205- text = f"Release { state .meta .tag or 'N/A' } — Status"
206-
207- if self .reply_in_thread :
208- await self .app .client .chat_postMessage (
209- channel = channel ,
210- thread_ts = thread_ts ,
211- text = text ,
212- blocks = blocks ,
213- reply_broadcast = self .broadcast_to_channel ,
214- )
215- else :
216- await self .app .client .chat_postMessage (
217- channel = channel ,
218- text = text ,
219- blocks = blocks ,
220- )
352+ # Post status using SlackStatePrinter
353+ printer = init_slack_printer (
354+ slack_token = self .bot_token ,
355+ slack_channel_id = channel ,
356+ thread_ts = thread_ts if self .reply_in_thread else None ,
357+ reply_broadcast = self .broadcast_to_channel ,
358+ )
359+ printer .update_message (state )
221360
222361 logger .info (
223362 f"Posted status for tag { tag } to channel { channel } "
@@ -251,6 +390,7 @@ async def run_bot(
251390 slack_app_token : Optional [str ] = None ,
252391 reply_in_thread : bool = True ,
253392 broadcast_to_channel : bool = False ,
393+ authorized_users : Optional [List [str ]] = None ,
254394) -> None :
255395 """Run the Slack bot.
256396
@@ -260,6 +400,7 @@ async def run_bot(
260400 slack_app_token: Slack app token (xapp-...). If None, uses SLACK_APP_TOKEN env var
261401 reply_in_thread: If True, reply in thread. If False, reply in main channel
262402 broadcast_to_channel: If True and reply_in_thread is True, also show in main channel
403+ authorized_users: List of user IDs authorized to run releases. If None, all users are authorized
263404 """
264405 # Load config
265406 config = load_config (config_path )
@@ -271,6 +412,7 @@ async def run_bot(
271412 slack_app_token = slack_app_token ,
272413 reply_in_thread = reply_in_thread ,
273414 broadcast_to_channel = broadcast_to_channel ,
415+ authorized_users = authorized_users ,
274416 )
275417
276418 await bot .start ()
0 commit comments