Skip to content

Commit 34f5205

Browse files
DavidRajnohaclaude
andcommitted
feat: implement Slack notifications (Option A + B) for CI loop
notify-slack.py supports two modes: - Option A (webhook): one-way notifications via SLACK_WEBHOOK_URL - Option B (bot): two-way with thread replies via SLACK_BOT_TOKEN Enables review window — agent waits for user feedback before pushing Integrated into /iterate-ci-flaky at key pause points: - ci_started: after triggering CI - ci_complete/ci_failed: after CI analysis - fix_applied: after committing fix, before push (with review window) - blocked: on REAL_REGRESSION or INFRA_ISSUE - iteration_done: final summary Falls back gracefully — prints to stdout if no Slack configured. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 84586e5 commit 34f5205

2 files changed

Lines changed: 404 additions & 3 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
#!/usr/bin/env python3
2+
"""Send Slack notifications for agentic test iteration loops.
3+
4+
Supports two modes based on environment variables:
5+
6+
Option A (Webhook — one-way):
7+
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../..."
8+
9+
Option B (Bot with thread replies — two-way):
10+
SLACK_BOT_TOKEN="xoxb-..."
11+
SLACK_CHANNEL_ID="C0123456789"
12+
13+
If neither is set, prints the message to stdout and exits cleanly.
14+
15+
Usage:
16+
# Send a notification (both modes)
17+
python3 notify-slack.py send <event_type> <message> [options]
18+
19+
# Wait for thread reply (Option B only)
20+
python3 notify-slack.py wait <message_ts> [--timeout 600]
21+
22+
Event types:
23+
fix_applied, ci_started, ci_complete, ci_failed,
24+
review_needed, iteration_done, flaky_found, blocked
25+
26+
Options:
27+
--pr <number> PR number (adds link to message)
28+
--branch <name> Branch name
29+
--url <ci_url> CI run URL
30+
--thread-ts <ts> Reply in a thread (Option B)
31+
--timeout <seconds> Review window timeout for 'wait' command (default: 600)
32+
"""
33+
34+
import argparse
35+
import json
36+
import os
37+
import subprocess
38+
import sys
39+
import time
40+
import urllib.request
41+
import urllib.error
42+
43+
44+
EMOJI = {
45+
"fix_applied": ":wrench:",
46+
"ci_started": ":hourglass_flowing_sand:",
47+
"ci_complete": ":white_check_mark:",
48+
"ci_failed": ":x:",
49+
"review_needed": ":eyes:",
50+
"iteration_done": ":checkered_flag:",
51+
"flaky_found": ":warning:",
52+
"blocked": ":octagonal_sign:",
53+
}
54+
55+
56+
def build_blocks(event_type, message, pr=None, branch=None, url=None):
57+
"""Build Slack Block Kit blocks for the notification."""
58+
emoji = EMOJI.get(event_type, ":robot_face:")
59+
60+
blocks = [
61+
{
62+
"type": "section",
63+
"text": {
64+
"type": "mrkdwn",
65+
"text": f"{emoji} *Agent: {event_type.replace('_', ' ').title()}*",
66+
},
67+
},
68+
{
69+
"type": "section",
70+
"text": {"type": "mrkdwn", "text": message},
71+
},
72+
]
73+
74+
context_parts = []
75+
if pr:
76+
context_parts.append(
77+
f"<https://github.com/openshift/monitoring-plugin/pull/{pr}|PR #{pr}>"
78+
)
79+
if branch:
80+
context_parts.append(f"Branch: `{branch}`")
81+
if url:
82+
context_parts.append(f"<{url}|CI Run>")
83+
84+
if context_parts:
85+
blocks.append(
86+
{
87+
"type": "context",
88+
"elements": [
89+
{"type": "mrkdwn", "text": " | ".join(context_parts)}
90+
],
91+
},
92+
)
93+
94+
return blocks
95+
96+
97+
def send_webhook(webhook_url, blocks):
98+
"""Option A: Send via incoming webhook."""
99+
payload = json.dumps({"blocks": blocks}).encode("utf-8")
100+
101+
req = urllib.request.Request(
102+
webhook_url,
103+
data=payload,
104+
headers={"Content-Type": "application/json"},
105+
method="POST",
106+
)
107+
108+
try:
109+
with urllib.request.urlopen(req) as resp:
110+
return {"ok": True, "status": resp.status}
111+
except urllib.error.HTTPError as e:
112+
print(f"Webhook failed: HTTP {e.code}{e.read().decode()}", file=sys.stderr)
113+
return {"ok": False, "error": str(e)}
114+
115+
116+
def slack_api(token, method, payload):
117+
"""Call a Slack Web API method."""
118+
url = f"https://slack.com/api/{method}"
119+
data = json.dumps(payload).encode("utf-8")
120+
121+
req = urllib.request.Request(
122+
url,
123+
data=data,
124+
headers={
125+
"Content-Type": "application/json; charset=utf-8",
126+
"Authorization": f"Bearer {token}",
127+
},
128+
method="POST",
129+
)
130+
131+
try:
132+
with urllib.request.urlopen(req) as resp:
133+
return json.loads(resp.read().decode())
134+
except urllib.error.HTTPError as e:
135+
body = e.read().decode()
136+
print(f"Slack API {method} failed: HTTP {e.code}{body}", file=sys.stderr)
137+
return {"ok": False, "error": str(e)}
138+
139+
140+
def send_bot(token, channel, blocks, thread_ts=None):
141+
"""Option B: Send via bot token."""
142+
payload = {
143+
"channel": channel,
144+
"blocks": blocks,
145+
}
146+
if thread_ts:
147+
payload["thread_ts"] = thread_ts
148+
149+
result = slack_api(token, "chat.postMessage", payload)
150+
151+
if result.get("ok"):
152+
ts = result.get("ts", "")
153+
print(f"MESSAGE_TS={ts}")
154+
return {"ok": True, "ts": ts}
155+
else:
156+
print(f"Bot send failed: {result.get('error')}", file=sys.stderr)
157+
return {"ok": False, "error": result.get("error")}
158+
159+
160+
def wait_for_reply(token, channel, message_ts, timeout=600, poll_interval=30):
161+
"""Option B: Poll for thread replies within a review window.
162+
163+
Returns the latest user reply text, or None if no reply within timeout.
164+
Output format:
165+
REPLY=<user's message text>
166+
NO_REPLY
167+
"""
168+
# Get bot's own user ID to filter out its own messages
169+
auth_result = slack_api(token, "auth.test", {})
170+
bot_user_id = auth_result.get("user_id", "")
171+
172+
deadline = time.time() + timeout
173+
seen_messages = set()
174+
175+
# Seed with the original message to ignore it
176+
seen_messages.add(message_ts)
177+
178+
print(f"Waiting up to {timeout}s for reply in thread {message_ts}...", flush=True)
179+
180+
while time.time() < deadline:
181+
result = slack_api(
182+
token,
183+
"conversations.replies",
184+
{"channel": channel, "ts": message_ts},
185+
)
186+
187+
if result.get("ok"):
188+
messages = result.get("messages", [])
189+
for msg in messages:
190+
msg_ts = msg.get("ts", "")
191+
user = msg.get("user", "")
192+
193+
if msg_ts in seen_messages:
194+
continue
195+
seen_messages.add(msg_ts)
196+
197+
# Skip bot's own messages
198+
if user == bot_user_id:
199+
continue
200+
201+
# Found a user reply
202+
reply_text = msg.get("text", "")
203+
print(f"REPLY={reply_text}")
204+
return reply_text
205+
206+
remaining = int(deadline - time.time())
207+
if remaining > 0:
208+
print(
209+
f"No reply yet, {remaining}s remaining...",
210+
file=sys.stderr,
211+
flush=True,
212+
)
213+
214+
time.sleep(min(poll_interval, max(1, remaining)))
215+
216+
print("NO_REPLY")
217+
return None
218+
219+
220+
def cmd_send(args):
221+
"""Handle the 'send' subcommand."""
222+
webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "")
223+
bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
224+
channel_id = os.environ.get("SLACK_CHANNEL_ID", "")
225+
226+
blocks = build_blocks(
227+
args.event_type, args.message, pr=args.pr, branch=args.branch, url=args.url
228+
)
229+
230+
# Option B: Bot token takes priority (supports two-way)
231+
if bot_token and channel_id:
232+
result = send_bot(bot_token, channel_id, blocks, thread_ts=args.thread_ts)
233+
return 0 if result.get("ok") else 1
234+
235+
# Option A: Webhook (one-way)
236+
if webhook_url:
237+
result = send_webhook(webhook_url, blocks)
238+
return 0 if result.get("ok") else 1
239+
240+
# No Slack configured — print to stdout and exit cleanly
241+
emoji = EMOJI.get(args.event_type, "")
242+
print(f"[slack-skip] {emoji} {args.event_type}: {args.message}")
243+
return 0
244+
245+
246+
def cmd_wait(args):
247+
"""Handle the 'wait' subcommand."""
248+
bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
249+
channel_id = os.environ.get("SLACK_CHANNEL_ID", "")
250+
251+
if not bot_token or not channel_id:
252+
print(
253+
"NO_REPLY (Option B not configured — SLACK_BOT_TOKEN and SLACK_CHANNEL_ID required)"
254+
)
255+
return 0
256+
257+
reply = wait_for_reply(
258+
bot_token, channel_id, args.message_ts, timeout=args.timeout
259+
)
260+
return 0
261+
262+
263+
def main():
264+
parser = argparse.ArgumentParser(
265+
description="Slack notifications for agentic test iteration"
266+
)
267+
subparsers = parser.add_subparsers(dest="command", required=True)
268+
269+
# 'send' subcommand
270+
send_parser = subparsers.add_parser("send", help="Send a notification")
271+
send_parser.add_argument(
272+
"event_type",
273+
choices=list(EMOJI.keys()),
274+
help="Event type",
275+
)
276+
send_parser.add_argument("message", help="Message text (Slack mrkdwn supported)")
277+
send_parser.add_argument("--pr", help="PR number")
278+
send_parser.add_argument("--branch", help="Branch name")
279+
send_parser.add_argument("--url", help="CI run URL")
280+
send_parser.add_argument(
281+
"--thread-ts", help="Thread timestamp to reply in (Option B)"
282+
)
283+
284+
# 'wait' subcommand
285+
wait_parser = subparsers.add_parser(
286+
"wait", help="Wait for thread reply (Option B only)"
287+
)
288+
wait_parser.add_argument("message_ts", help="Message timestamp to watch")
289+
wait_parser.add_argument(
290+
"--timeout",
291+
type=int,
292+
default=600,
293+
help="Seconds to wait for reply (default: 600)",
294+
)
295+
296+
args = parser.parse_args()
297+
298+
if args.command == "send":
299+
return cmd_send(args)
300+
elif args.command == "wait":
301+
return cmd_wait(args)
302+
303+
304+
if __name__ == "__main__":
305+
sys.exit(main())

0 commit comments

Comments
 (0)