Skip to content

Commit c68ffec

Browse files
committed
Authorized users for slack, args to post in specific thread
1 parent c81466a commit c68ffec

File tree

5 files changed

+215
-33
lines changed

5 files changed

+215
-33
lines changed

src/redis_release/bht/tree.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ def initialize_tree_and_state(
134134
if args.slack_token or args.slack_channel_id:
135135
try:
136136
slack_printer = init_slack_printer(
137-
args.slack_token, args.slack_channel_id
137+
args.slack_token,
138+
args.slack_channel_id,
139+
args.slack_thread_ts,
140+
args.slack_reply_broadcast,
138141
)
139142
# Capture the non-None printer in the closure
140143
printer = slack_printer

src/redis_release/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ def slack_bot(
213213
"--broadcast/--no-broadcast",
214214
help="When replying in thread, also show in main channel",
215215
),
216+
authorized_users: Optional[List[str]] = typer.Option(
217+
None,
218+
"--authorized-user",
219+
help="User ID authorized to run releases (can be specified multiple times). If not specified, all users are authorized",
220+
),
216221
) -> None:
217222
"""Run Slack bot that listens for status requests.
218223
@@ -222,6 +227,9 @@ def slack_bot(
222227
By default, replies are posted in threads to keep channels clean. Use --no-reply-in-thread
223228
to post directly in the channel. Use --broadcast to show thread replies in the main channel.
224229
230+
Only users specified with --authorized-user can run releases. Status command is available to all users.
231+
You can also include the word 'broadcast' in the release message to broadcast updates to the main channel.
232+
225233
Requires Socket Mode to be enabled in your Slack app configuration.
226234
"""
227235
from redis_release.slack_bot import run_bot
@@ -237,6 +245,7 @@ def slack_bot(
237245
slack_app_token,
238246
reply_in_thread,
239247
broadcast_to_channel,
248+
authorized_users,
240249
)
241250
)
242251

src/redis_release/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,5 @@ class ReleaseArgs(BaseModel):
163163
override_state_name: Optional[str] = None
164164
slack_token: Optional[str] = None
165165
slack_channel_id: Optional[str] = None
166+
slack_thread_ts: Optional[str] = None
167+
slack_reply_broadcast: bool = False

src/redis_release/slack_bot.py

Lines changed: 165 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44
import logging
55
import os
66
import re
7-
from typing import Any, Dict, Optional
7+
import threading
8+
from typing import Any, Dict, List, Optional
89

910
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
1011
from slack_bolt.async_app import AsyncApp
1112
from slack_bolt.context.say.async_say import AsyncSay
1213

14+
from redis_release.bht.tree import async_tick_tock, initialize_tree_and_state
1315
from redis_release.config import Config, load_config
1416
from redis_release.models import ReleaseArgs
1517
from 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

1820
logger = 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

Comments
 (0)