Skip to content

Commit b2923f6

Browse files
committed
Added updating slack output
1 parent 4c4f765 commit b2923f6

File tree

6 files changed

+309
-2
lines changed

6 files changed

+309
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"pydantic>=2.0.0",
3131
"py_trees>=2.2,<3.0",
3232
"pyyaml>=6.0.3",
33+
"slack-sdk>=3.38.0",
3334
]
3435

3536
[project.optional-dependencies]

src/redis_release/bht/tree.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..models import ReleaseArgs
2424
from ..state_display import print_state_table
2525
from ..state_manager import S3StateStorage, StateManager, StateStorage
26+
from ..state_slack import SlackStatePrinter, init_slack_printer
2627
from .backchain import latch_chains
2728
from .behaviours import NeedToPublishRelease
2829
from .composites import (
@@ -124,6 +125,24 @@ def initialize_tree_and_state(
124125
tree.add_post_tick_handler(lambda _: state_syncer.sync())
125126
tree.add_post_tick_handler(log_tree_state_with_markup)
126127

128+
# Initialize Slack printer if Slack args are provided
129+
slack_printer: Optional[SlackStatePrinter] = None
130+
if args.slack_token or args.slack_channel_id:
131+
try:
132+
slack_printer = init_slack_printer(
133+
args.slack_token, args.slack_channel_id
134+
)
135+
# Capture the non-None printer in the closure
136+
printer = slack_printer
137+
138+
def slack_tick_handler(_: BehaviourTree) -> None:
139+
printer.update_message(state_syncer.state)
140+
141+
tree.add_post_tick_handler(slack_tick_handler)
142+
except ValueError as e:
143+
logger.error(f"Failed to initialize Slack printer: {e}")
144+
slack_printer = None
145+
127146
try:
128147
yield (tree, state_syncer)
129148
finally:

src/redis_release/cli.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import asyncio
44
import logging
5-
import os
65
from typing import List, Optional
76

87
import typer
@@ -15,6 +14,7 @@
1514
S3StateStorage,
1615
StateManager,
1716
)
17+
from redis_release.state_slack import init_slack_printer
1818

1919
from .bht.tree import TreeInspector, async_tick_tock, initialize_tree_and_state
2020
from .config import load_config
@@ -112,6 +112,16 @@ def release(
112112
"--override-state-name",
113113
help="Custom state name to use instead of release tag, to be able to make test runs without affecting production state",
114114
),
115+
slack_token: Optional[str] = typer.Option(
116+
None,
117+
"--slack-token",
118+
help="Slack bot token (if not provided, uses SLACK_BOT_TOKEN env var)",
119+
),
120+
slack_channel_id: Optional[str] = typer.Option(
121+
None,
122+
"--slack-channel-id",
123+
help="Slack channel ID to post status updates to",
124+
),
115125
) -> None:
116126
"""Run release using behaviour tree implementation."""
117127
setup_logging()
@@ -125,6 +135,8 @@ def release(
125135
only_packages=only_packages or [],
126136
force_release_type=force_release_type,
127137
override_state_name=override_state_name,
138+
slack_token=slack_token,
139+
slack_channel_id=slack_channel_id,
128140
)
129141

130142
# Use context manager version with automatic lock management
@@ -138,8 +150,19 @@ def status(
138150
config_file: Optional[str] = typer.Option(
139151
None, "--config", "-c", help="Path to config file (default: config.yaml)"
140152
),
153+
slack: bool = typer.Option(False, "--slack", help="Post status to Slack"),
154+
slack_channel_id: Optional[str] = typer.Option(
155+
None,
156+
"--slack-channel-id",
157+
help="Slack channel ID to post to (required if --slack is used)",
158+
),
159+
slack_token: Optional[str] = typer.Option(
160+
None,
161+
"--slack-token",
162+
help="Slack bot token (if not provided, uses SLACK_BOT_TOKEN env var)",
163+
),
141164
) -> None:
142-
"""Run release using behaviour tree implementation."""
165+
"""Display release status in console and optionally post to Slack."""
143166
setup_logging()
144167
config_path = config_file or "config.yaml"
145168
config = load_config(config_path)
@@ -156,8 +179,14 @@ def status(
156179
args=args,
157180
read_only=True,
158181
) as state_syncer:
182+
# Always print to console
159183
print_state_table(state_syncer.state)
160184

185+
# Post to Slack if requested
186+
if slack:
187+
printer = init_slack_printer(slack_token, slack_channel_id)
188+
printer.update_message(state_syncer.state)
189+
161190

162191
if __name__ == "__main__":
163192
app()

src/redis_release/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,5 @@ class ReleaseArgs(BaseModel):
161161
only_packages: List[str] = Field(default_factory=list)
162162
force_release_type: Optional[ReleaseType] = None
163163
override_state_name: Optional[str] = None
164+
slack_token: Optional[str] = None
165+
slack_channel_id: Optional[str] = None

src/redis_release/state_slack.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Slack display utilities for release state."""
2+
3+
import json
4+
import logging
5+
import os
6+
from typing import Any, Dict, List, Optional, Tuple
7+
8+
from slack_sdk import WebClient
9+
from slack_sdk.errors import SlackApiError
10+
11+
from redis_release.state_display import DisplayModel, StepStatus
12+
13+
from .bht.state import Package, ReleaseState, Workflow
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def init_slack_printer(
19+
slack_token: Optional[str], slack_channel_id: Optional[str]
20+
) -> "SlackStatePrinter":
21+
"""Initialize SlackStatePrinter with validation.
22+
23+
Args:
24+
slack_token: Slack bot token (if None, uses SLACK_BOT_TOKEN env var)
25+
slack_channel_id: Slack channel ID to post to
26+
27+
Returns:
28+
SlackStatePrinter instance
29+
30+
Raises:
31+
ValueError: If channel_id is not provided or token is not available
32+
"""
33+
if not slack_channel_id:
34+
raise ValueError("Slack channel ID is required")
35+
36+
# Get token from argument or environment variable
37+
token = slack_token or os.environ.get("SLACK_BOT_TOKEN")
38+
if not token:
39+
raise ValueError(
40+
"Slack token not provided. Use slack_token argument or set SLACK_BOT_TOKEN environment variable"
41+
)
42+
43+
return SlackStatePrinter(token, slack_channel_id)
44+
45+
46+
class SlackStatePrinter:
47+
"""Handles posting and updating release state to Slack channel."""
48+
49+
def __init__(self, slack_token: str, slack_channel_id: str):
50+
"""Initialize the Slack printer.
51+
52+
Args:
53+
slack_token: Slack bot token
54+
slack_channel_id: Slack channel ID to post messages to
55+
"""
56+
self.client = WebClient(token=slack_token)
57+
self.channel_id = slack_channel_id
58+
self.message_ts: Optional[str] = None
59+
self.last_blocks_json: Optional[str] = None
60+
61+
def update_message(self, state: ReleaseState) -> bool:
62+
"""Post or update Slack message with release state.
63+
64+
Only updates if the blocks have changed since last update.
65+
66+
Args:
67+
state: The ReleaseState to display
68+
69+
Returns:
70+
True if message was posted/updated, False if no change
71+
"""
72+
blocks = self._make_blocks(state)
73+
blocks_json = json.dumps(blocks, sort_keys=True)
74+
75+
# Check if blocks have changed
76+
if blocks_json == self.last_blocks_json:
77+
logger.debug("Slack message unchanged, skipping update")
78+
return False
79+
80+
text = f"Release {state.meta.tag or 'N/A'} — Status"
81+
82+
try:
83+
if self.message_ts is None:
84+
# Post new message
85+
response = self.client.chat_postMessage(
86+
channel=self.channel_id,
87+
text=text,
88+
blocks=blocks,
89+
)
90+
self.message_ts = response["ts"]
91+
# Update channel_id from response (authoritative)
92+
self.channel_id = response["channel"]
93+
logger.info(f"Posted Slack message ts={self.message_ts}")
94+
else:
95+
# Update existing message
96+
self.client.chat_update(
97+
channel=self.channel_id,
98+
ts=self.message_ts,
99+
text=text,
100+
blocks=blocks,
101+
)
102+
logger.debug(f"Updated Slack message ts={self.message_ts}")
103+
104+
self.last_blocks_json = blocks_json
105+
return True
106+
107+
except SlackApiError as e:
108+
error_msg = getattr(e.response, "get", lambda x: "Unknown error")("error") if hasattr(e, "response") else str(e) # type: ignore
109+
logger.error(f"Slack API error: {error_msg}")
110+
raise
111+
112+
def _make_blocks(self, state: ReleaseState) -> List[Dict[str, Any]]:
113+
"""Create Slack blocks for the release state.
114+
115+
Args:
116+
state: The ReleaseState to display
117+
118+
Returns:
119+
List of Slack block dictionaries
120+
"""
121+
blocks: List[Dict[str, Any]] = []
122+
123+
# Header
124+
blocks.append(
125+
{
126+
"type": "header",
127+
"text": {
128+
"type": "plain_text",
129+
"text": f"Release {state.meta.tag or 'N/A'} — Status",
130+
},
131+
}
132+
)
133+
134+
# Legend
135+
blocks.append(
136+
{
137+
"type": "context",
138+
"elements": [
139+
{
140+
"type": "mrkdwn",
141+
"text": "*Legend:* ✅ Success • ❌ Failed • ⏳ In progress • ⚪ Not started",
142+
}
143+
],
144+
}
145+
)
146+
147+
blocks.append({"type": "divider"})
148+
149+
# Process each package
150+
for package_name, package in sorted(state.packages.items()):
151+
# Get workflow statuses
152+
build_status_emoji = self._get_status_emoji(package, package.build)
153+
publish_status_emoji = self._get_status_emoji(package, package.publish)
154+
155+
# Package section
156+
blocks.append(
157+
{
158+
"type": "section",
159+
"text": {
160+
"type": "mrkdwn",
161+
"text": f"*{package_name}*\n*Build:* {build_status_emoji} | *Publish:* {publish_status_emoji}",
162+
},
163+
}
164+
)
165+
166+
# Workflow details in context
167+
build_details = self._collect_workflow_details_slack(package, package.build)
168+
publish_details = self._collect_workflow_details_slack(
169+
package, package.publish
170+
)
171+
172+
if build_details or publish_details:
173+
elements = []
174+
if build_details:
175+
elements.append(
176+
{"type": "mrkdwn", "text": f"*Build Workflow*\n{build_details}"}
177+
)
178+
if publish_details:
179+
elements.append(
180+
{
181+
"type": "mrkdwn",
182+
"text": f"*Publish Workflow*\n{publish_details}",
183+
}
184+
)
185+
blocks.append({"type": "context", "elements": elements})
186+
187+
blocks.append({"type": "divider"})
188+
189+
return blocks
190+
191+
def _get_status_emoji(self, package: Package, workflow: Workflow) -> str:
192+
"""Get emoji status for a workflow.
193+
194+
Args:
195+
package: The package containing the workflow
196+
workflow: The workflow to check
197+
198+
Returns:
199+
Emoji status string
200+
"""
201+
workflow_status = DisplayModel.get_workflow_status(package, workflow)
202+
status = workflow_status[0]
203+
204+
if status == StepStatus.SUCCEEDED:
205+
return "✅ Success"
206+
elif status == StepStatus.RUNNING:
207+
return "⏳ In progress"
208+
elif status == StepStatus.NOT_STARTED:
209+
return "⚪ Not started"
210+
elif status == StepStatus.INCORRECT:
211+
return "⚠️ Invalid state"
212+
else: # FAILED
213+
return "❌ Failed"
214+
215+
def _collect_workflow_details_slack(
216+
self, package: Package, workflow: Workflow
217+
) -> str:
218+
"""Collect workflow step details for Slack display.
219+
220+
Args:
221+
package: The package containing the workflow
222+
workflow: The workflow to check
223+
224+
Returns:
225+
Formatted string of workflow steps
226+
"""
227+
workflow_status = DisplayModel.get_workflow_status(package, workflow)
228+
if workflow_status[0] == StepStatus.NOT_STARTED:
229+
return ""
230+
231+
details: List[str] = []
232+
233+
for step_status, step_name, step_message in workflow_status[1]:
234+
if step_status == StepStatus.SUCCEEDED:
235+
details.append(f"• ✅ {step_name}")
236+
elif step_status == StepStatus.RUNNING:
237+
details.append(f"• ⏳ {step_name}")
238+
elif step_status == StepStatus.NOT_STARTED:
239+
details.append(f"• ⚪ {step_name}")
240+
else: # FAILED or INCORRECT
241+
msg = f" ({step_message})" if step_message else ""
242+
details.append(f"• ❌ {step_name}{msg}")
243+
break
244+
245+
return "\n".join(details)

0 commit comments

Comments
 (0)