Files
Alexey MilovidovandClaude Opus 4.8 295ced78d1 Enable more Ruff checks (F401/F403/F405/F541/F841) and clean up tests
Follow-up to #105171, which added `ruff` with a deliberately narrow,
bug-class-only rule set and left the unused-name and star-import rules
off pending a dedicated cleanup pass. This enables that second batch in
`pyproject.toml` and fixes every violation across the tree (the bulk in
integration tests):

- `F401` unused-import and `F841` unused-variable: removed, preserving
  any side-effecting right-hand side (e.g. `result = node.query(...)`
  becomes `node.query(...)`). Two `try: import ...` feature probes are
  kept with `# noqa: F401`.
- `F541` f-string-missing-placeholders: dropped the redundant `f` prefix.
- `F403`/`F405` star imports: every `from module import *` is expanded
  into explicit imports of the names actually used, taken from each
  name's real origin, so stdlib/third-party names import from their real
  source rather than through a test "convenience bundle". The now-empty
  `tests/integration/helpers/kafka/common_direct.py` bundle is removed and
  its three remaining consumers import `_VarintBytes`/`KafkaAdminClient`
  directly.

Eliminating the star imports surfaced a real bug the wildcard had masked
from `F821`: `generate_presets.py` in `test_prometheus_protocols` called
an undefined `execute_instant_query_with_http_api`; fixed to the existing
`execute_query_via_http_api`.

No `black`/`isort` reformatting was applied (neither is enforced in the
style check), so the diff is limited to the lint fixes themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-18 23:22:25 +00:00

1032 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
from event import EventFeed, FeedSubscription
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "")
# Module-level cache for github_login -> user_ids mapping
# Reused across Lambda invocations within the same container
SUBSCRIPTION_CACHE = {}
def _default_prefs() -> dict:
return {
"hide_merged_prs": False,
"hide_merges": False,
"hide_secondary_prs": False,
"show_last_7d": False,
"notify_on_complete": False,
"notify_on_failure": False,
"last_notified_key": {},
}
def _get_subscription_cached(user_email: str, s3_path: str):
cached = SUBSCRIPTION_CACHE.get(user_email)
if isinstance(cached, FeedSubscription):
return cached
# Backward compatibility: older cache entries stored just the list of user_ids
if isinstance(cached, list):
return FeedSubscription(
user_ids=cached, user_email=user_email, subscribed_at=""
)
if not s3_path:
return FeedSubscription(user_ids=[], user_email=user_email, subscribed_at="")
subscription = FeedSubscription.get_subscription(user_email, s3_path=s3_path)
SUBSCRIPTION_CACHE[user_email] = subscription
return subscription
def load_user_prefs(user_id: str, user_email: str, s3_path: str) -> dict:
if not user_id or not user_email or not s3_path:
return _default_prefs()
subscription = _get_subscription_cached(user_email, s3_path=s3_path)
user_prefs = (
subscription.user_prefs if isinstance(subscription.user_prefs, dict) else {}
)
prefs = user_prefs.get(user_id, {}) if isinstance(user_prefs, dict) else {}
merged = _default_prefs()
if isinstance(prefs, dict):
merged.update({k: prefs.get(k, v) for k, v in merged.items()})
return merged
def save_user_prefs(user_id: str, user_email: str, prefs: dict, s3_path: str) -> None:
if not user_id or not user_email or not s3_path:
return
subscription = _get_subscription_cached(user_email, s3_path=s3_path)
if not isinstance(subscription.user_prefs, dict):
subscription.user_prefs = {}
subscription.user_prefs[user_id] = prefs
subscription.to_s3(s3_path)
SUBSCRIPTION_CACHE[user_email] = subscription
def toggle_pref(user_id: str, user_email: str, pref_key: str, s3_path: str) -> dict:
prefs = load_user_prefs(user_id, user_email, s3_path)
if pref_key not in prefs:
return prefs
if not isinstance(prefs.get(pref_key), bool):
return prefs
prefs[pref_key] = not prefs[pref_key]
save_user_prefs(user_id, user_email, prefs, s3_path)
return prefs
def get_user_email(user_id: str) -> str:
"""
Fetch user email from Slack API using user ID.
"""
import urllib.error
import urllib.request
if not SLACK_BOT_TOKEN:
print("Error: SLACK_BOT_TOKEN not configured")
return ""
try:
req = urllib.request.Request(
f"https://slack.com/api/users.info?user={user_id}",
headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"},
)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read())
if data.get("ok"):
return data.get("user", {}).get("profile", {}).get("email", "")
else:
print(f"Slack API error: {data.get('error')}")
return ""
except Exception as e:
print(f"Failed to get user email: {e}")
return ""
def post_to_response_url(response_url: str, payload: dict) -> None:
"""
Post a follow-up message to Slack via response_url.
"""
import urllib.error
import urllib.request
if not response_url:
print("Warning: No response_url provided, skipping follow-up message")
return
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
response_url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as response:
result = response.read()
print(f"Posted to response_url: {result.decode('utf-8')}")
except urllib.error.HTTPError as e:
print(f"Failed to post to response_url: {e.code} - {e.read().decode('utf-8')}")
except Exception as e:
print(f"Failed to post to response_url: {e}")
def _open_dm_channel(user_id: str) -> str:
import urllib.request
if not SLACK_BOT_TOKEN or not user_id:
return ""
body = json.dumps({"users": user_id}).encode("utf-8")
req = urllib.request.Request(
"https://slack.com/api/conversations.open",
data=body,
headers={
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read())
if not data.get("ok"):
print(f"conversations.open failed: {data}")
return ""
return data.get("channel", {}).get("id", "")
except Exception as e:
print(f"conversations.open error: {e}")
return ""
def _post_dm(user_id: str, user_email: str, s3_path: str, text: str) -> None:
import urllib.request
prefs = (
load_user_prefs(user_id, user_email, s3_path) if user_email and s3_path else {}
)
channel_id = prefs.get("dm_channel_id") if isinstance(prefs, dict) else ""
if not channel_id:
channel_id = _open_dm_channel(user_id)
if channel_id and isinstance(prefs, dict) and user_email and s3_path:
prefs["dm_channel_id"] = channel_id
save_user_prefs(
user_id=user_id, user_email=user_email, prefs=prefs, s3_path=s3_path
)
if not channel_id:
return
body = json.dumps({"channel": channel_id, "text": text}).encode("utf-8")
req = urllib.request.Request(
"https://slack.com/api/chat.postMessage",
data=body,
headers={
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read())
if not data.get("ok"):
print(f"chat.postMessage failed: {data}")
# If cached channel is invalid, refresh it once
if isinstance(prefs, dict) and prefs.get("dm_channel_id") == channel_id:
new_channel_id = _open_dm_channel(user_id)
if (
new_channel_id
and new_channel_id != channel_id
and user_email
and s3_path
):
prefs["dm_channel_id"] = new_channel_id
save_user_prefs(
user_id=user_id,
user_email=user_email,
prefs=prefs,
s3_path=s3_path,
)
except Exception as e:
print(f"chat.postMessage error: {e}")
def format_event_text(event, pr_status, indent=""):
"""Format event text with optional indentation for linked events."""
# PR status emoji
pr_status_emoji = ":pr_open:" if pr_status == "open" else ":pr_merged:"
# Get change URL from event.ext
change_url = event.ext.get("change_url", "")
is_cancelled = bool(event.ext.get("is_cancelled", False))
# Check if any job has failed or errored
has_failures = False
if hasattr(event, "result") and event.result and event.result.get("results"):
has_failures = any(
(r.get("status") or "").lower() in ["failure", "error", "fail"]
for r in event.result.get("results", [])
)
# CI status emoji based on event.ci_status
ci_status_lower = (event.ci_status or "").lower()
if ci_status_lower in ("pending", "running"):
ci_running_status_emoji = ":job_running:"
else:
if not is_cancelled:
ci_running_status_emoji = ":checkered_flag:"
else:
ci_running_status_emoji = ":job_cancelled:"
if ci_status_lower in ("success", "ok"):
ci_status_emoji = ":success_sign:"
elif ci_status_lower in ("pending", "running"):
ci_status_emoji = ":failure_sign:" if has_failures else ":job_running:"
else:
ci_status_emoji = ":failure_sign:"
# Get report URL from result.ext if available
report_url = ""
if hasattr(event, "result") and event.result:
report_url = event.result.get("ext", {}).get("report_url", "")
if report_url:
report_url_text = f"<{report_url}|*report*>"
else:
report_url_text = ""
# Format identifier (sha or PR number) with link if available
event_pr_number = event.ext.get("pr_number", 0)
if event_pr_number > 0:
identifier = f"#{event_pr_number}"
title = event.ext.get("pr_title", "")
else:
identifier = event.sha[:8] if event.sha else "commit"
title = (
event.ext.get("commit_message", "").split("\n")[0]
if event.ext.get("commit_message")
else event.ext.get("pr_title", "")
)
identifier_text = f"<{change_url}|{identifier}>" if change_url else identifier
workflow_name = event.ext.get("workflow_name", "")
repo_name = event.ext.get("repo_name", "")
workflow_suffix = ""
if workflow_name:
workflow_suffix = f" [{workflow_name}]"
if repo_name:
workflow_suffix += f"[{repo_name}]"
event_text = f"{indent}{pr_status_emoji} {ci_running_status_emoji} {ci_status_emoji} {identifier_text} *{title}*{workflow_suffix}"
# Aggregate results by status from workflow result
if hasattr(event, "result") and event.result and event.result.get("results"):
fail_cnt = 0
error_cnt = 0
dropped_cnt = 0
success_cnt = 0
skipped_cnt = 0
running_cnt = 0
total_cnt = 0
for r in event.result.get("results", []):
status = (r.get("status") or "").lower()
total_cnt += 1
if status in ("failure", "fail"):
fail_cnt += 1
elif status == "error":
error_cnt += 1
elif status == "dropped":
dropped_cnt += 1
elif status in ("success", "ok"):
success_cnt += 1
elif status == "skipped":
skipped_cnt += 1
elif status in ("pending", "running"):
running_cnt += 1
# Build compact one-line summary
parts = []
if fail_cnt or error_cnt:
parts.append(f"{fail_cnt + error_cnt} failed")
if dropped_cnt:
parts.append(f"{dropped_cnt} dropped")
if running_cnt:
parts.append(f"{running_cnt} running")
if success_cnt:
parts.append(f"{success_cnt} ok")
if skipped_cnt:
parts.append(f"{skipped_cnt} skipped")
if parts:
summary = " · ".join(parts)
if report_url_text:
event_text += f"\n{indent}{summary} · {report_url_text}"
else:
event_text += f"\n{indent}{summary}"
elif report_url_text:
event_text += f"\n{indent}{report_url_text}"
return event_text
def _format_notification_text(event, notify_type: str) -> str:
note = f"_(notification: {notify_type})_"
ext = getattr(event, "ext", {}) or {}
pr_status = (ext.get("pr_status") or "").lower()
base = format_event_text(event, pr_status, indent="")
failed_names = []
result = getattr(event, "result", None)
if isinstance(result, dict):
results = result.get("results", []) or []
elif result is not None and hasattr(result, "results"):
results = getattr(result, "results") or []
else:
results = []
for r in results:
if not isinstance(r, dict):
continue
status = (r.get("status") or "").lower()
if status not in ("failure", "error", "fail"):
continue
name = r.get("name") or ""
if name:
failed_names.append(name)
extra = ""
if failed_names:
extra = "\n_Failed jobs:_ " + ", ".join(f"`{n}`" for n in failed_names)
return f"{base}\n{note}{extra}"
def load_event_timeline(github_login: str):
"""
Load EventFeed from S3.
Args:
github_login: GitHub login to construct S3 key
Returns:
EventFeed object
"""
if not github_login:
return EventFeed()
s3_path = os.environ.get("EVENT_FEED_S3_PATH", "")
assert s3_path
try:
# Load EventFeed from S3
timeline = EventFeed.from_s3(github_login, s3_path=s3_path)
print(f"Loaded EventFeed with {len(timeline.events)} events")
return timeline
except Exception as e:
print(f"Error loading EventFeed: {e}")
return EventFeed()
def publish_home_view(
user_id: str,
username: str,
github_login: str = "",
events: list = None,
) -> None:
"""
Publish or update the Slack home tab view for a user.
"""
import urllib.request
events = events or []
subscriptions_s3_path = os.environ.get("EVENT_FEED_S3_PATH", "")
prefs = load_user_prefs(user_id, github_login, subscriptions_s3_path)
def _is_merge_result(event) -> bool:
ext = getattr(event, "ext", {}) or {}
branch = (ext.get("branch") or "").lower()
pr_number = ext.get("pr_number", 0) or 0
if pr_number:
return False
if branch in ("master", "main"):
return True
if branch.startswith("release"):
return True
return False
import time
merge_result_parent_keys = set()
for e in events:
ext = getattr(e, "ext", {}) or {}
e_pr_number = ext.get("pr_number", 0) or 0
if e_pr_number != 0:
continue
e_parent_pr_number = ext.get("parent_pr_number", 0) or 0
if not e_parent_pr_number:
continue
e_repo_name = ext.get("repo_name", "") or ""
if not e_repo_name:
continue
merge_result_parent_keys.add((e_repo_name, e_parent_pr_number))
filtered_events = []
for e in events:
ext = getattr(e, "ext", {}) or {}
pr_status = (ext.get("pr_status") or "").lower()
pr_number = ext.get("pr_number", 0) or 0
parent_pr_number = ext.get("parent_pr_number", 0) or 0
repo_name = ext.get("repo_name", "") or ""
# Workaround (backward compat): older EventFeed entries may miss ext.pr_status.
# Populate it here so the rest of the function can rely on it.
if not pr_status:
if pr_number == 0:
pr_status = "merged"
elif (
pr_number > 0
and repo_name
and (repo_name, pr_number) in merge_result_parent_keys
):
pr_status = "merged"
elif pr_number > 0:
pr_status = "open"
if pr_status:
ext["pr_status"] = pr_status
if prefs.get("show_last_7d"):
ts = getattr(e, "timestamp", 0) or 0
if ts and ts < int(time.time()) - 7 * 24 * 60 * 60:
continue
if prefs.get("hide_merged_prs") and pr_number > 0 and pr_status == "merged":
continue
if prefs.get("hide_merges") and _is_merge_result(e):
continue
if prefs.get("hide_secondary_prs") and parent_pr_number > 0 and pr_number > 0:
continue
filtered_events.append(e)
events = filtered_events
blocks = []
def _btn(label: str, action_id: str) -> dict:
return {
"type": "button",
"text": {"type": "plain_text", "text": label, "emoji": True},
"action_id": action_id,
"value": "toggle",
}
blocks.append(
{
"type": "actions",
"elements": [
_btn(
f"Merged PRs: {'On' if not prefs.get('hide_merged_prs') else 'Off'}",
"toggle_hide_merged_prs",
),
_btn(
f"Merges: {'On' if not prefs.get('hide_merges') else 'Off'}",
"toggle_hide_merges",
),
_btn(
f"Auxilary PRs: {'On' if not prefs.get('hide_secondary_prs') else 'Off'}",
"toggle_hide_secondary_prs",
),
_btn(
f"< 7d: {'On' if prefs.get('show_last_7d') else 'Off'}",
"toggle_show_last_7d",
),
_btn(
f"Notify complete: {'On' if prefs.get('notify_on_complete') else 'Off'}",
"toggle_notify_on_complete",
),
_btn(
f"Notify failure: {'On' if prefs.get('notify_on_failure') else 'Off'}",
"toggle_notify_on_failure",
),
],
}
)
# Build nested structure by parent_pr_number
# 1. Build map of pr_number -> event for quick lookup
# 2. Find ultimate root parent for each event (flatten hierarchy)
# 3. Group children by their ultimate root parent
# 4. Parents are events with no parent (parent_pr_number=0 or not in list)
# Build lookup map
pr_to_event = {}
for event in events:
pr_number = event.ext.get("pr_number", 0)
if pr_number > 0:
pr_to_event[pr_number] = event
# Helper function to find ultimate root parent for an event
def find_root_parent(event):
"""Trace back through parent chain to find ultimate root parent."""
visited = set()
current = event
while True:
pr_number = current.ext.get("pr_number", 0)
parent_pr_number = current.ext.get("parent_pr_number", 0)
# Avoid infinite loops
if pr_number in visited:
return current
visited.add(pr_number)
# If no parent or parent not in list, this is the root
if parent_pr_number == 0 or parent_pr_number not in pr_to_event:
return current
# Move up to parent
current = pr_to_event[parent_pr_number]
# Group events by their ultimate root parent
parent_events = []
parent_pr_numbers = set()
children_by_parent = {} # root_pr_number -> list of child events
for event in events:
root_parent = find_root_parent(event)
root_pr_number = root_parent.ext.get("pr_number", 0)
if event is root_parent:
# This event is a root parent itself
if root_pr_number == 0:
parent_events.append(event)
elif root_pr_number > 0 and root_pr_number not in parent_pr_numbers:
parent_events.append(event)
parent_pr_numbers.add(root_pr_number)
else:
# This event is a child of a root parent
if root_pr_number not in children_by_parent:
children_by_parent[root_pr_number] = []
children_by_parent[root_pr_number].append(event)
root_events = parent_events
# Add events list if available (events are already sorted newest first)
if root_events:
for root_event in root_events:
pr_number = root_event.ext.get("pr_number", 0)
# Determine PR status based on children
# If any child has pr_number = 0, the PR is merged
children = children_by_parent.get(pr_number, [])
ext = getattr(root_event, "ext", {}) or {}
pr_status = (ext.get("pr_status") or "").lower()
for child in children:
if child.ext.get("pr_number", 0) == 0:
pr_status = "merged"
break
# Format parent event
event_text = format_event_text(root_event, pr_status, indent="")
# Add parent event section
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": event_text,
},
}
)
# Add child events with indentation (sorted by timestamp, newest first)
if children:
children_sorted = sorted(
children, key=lambda e: e.timestamp, reverse=True
)
for child_event in children_sorted:
child_text = format_event_text(child_event, pr_status, indent="> ")
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": child_text,
},
}
)
# Add divider after the event group
blocks.append({"type": "divider"})
# Add footer with divider
if github_login:
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"_{github_login}_",
},
}
)
# Slack home views are limited to 100 blocks total.
# Truncate before the limit, leaving room for a notice block.
SLACK_BLOCK_LIMIT = 100
if len(blocks) > SLACK_BLOCK_LIMIT:
blocks = blocks[: SLACK_BLOCK_LIMIT - 1]
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "_Some events were truncated (view limit reached)._",
},
}
)
# Slack section text is capped at 3000 characters.
for block in blocks:
if block.get("type") == "section":
txt = block.get("text", {})
if isinstance(txt, dict) and len(txt.get("text", "")) > 3000:
txt["text"] = txt["text"][:2997] + "…"
view_data = {
"user_id": user_id,
"view": {
"type": "home",
"blocks": blocks,
},
}
data = json.dumps(view_data).encode("utf-8")
req = urllib.request.Request(
"https://slack.com/api/views.publish",
data=data,
headers={
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=3) as resp:
result = json.loads(resp.read().decode("utf-8"))
if result.get("ok"):
print(f"Home view published successfully for user {user_id}")
else:
print(f"Error publishing home view: {result.get('error')}")
except Exception as e:
print(f"Failed to publish home view: {e}")
def lambda_handler(event, context):
"""
Worker Lambda that processes subscribe and update requests:
- subscribe: Saves subscription details, loads events, publishes home view
- update: Loads events and publishes home view (no subscription save)
Note: 'github_login' parameter now contains user email addresses for email-based subscriptions.
"""
# Clear the per-container cache at the start of every invocation so that
# preference changes saved to S3 by a previous invocation (e.g. toggle_pref)
# are always reflected in subsequent invocations running in the same container.
SUBSCRIPTION_CACHE.clear()
print(f"Worker Lambda invoked with event: {json.dumps(event)}")
action = event.get("action", "")
user_id = event.get("user_id", "")
username = event.get("username", "")
user_email = event.get("github_login", "") # Contains email address
response_url = event.get("response_url", "")
pref_key = event.get("pref_key", "")
if not action:
print("Error: Missing action parameter")
return {"statusCode": 400, "body": "Missing action"}
print(f"Processing {action} for user {user_id} ({username}), Email: {user_email}")
subscriptions_s3_path = os.environ.get("EVENT_FEED_S3_PATH", "")
if action == "toggle_pref":
try:
if not user_id:
raise ValueError("Missing user_id")
if not pref_key:
raise ValueError("Missing pref_key")
target_email = FeedSubscription.find_user_subscription(
user_id, s3_path=subscriptions_s3_path
)
if not target_email:
target_email = user_email
if not target_email:
raise ValueError("No subscription found for user")
prefs = toggle_pref(
user_id=user_id,
user_email=target_email,
pref_key=pref_key,
s3_path=subscriptions_s3_path,
)
print(f"Toggled pref {pref_key} for {user_id}: {prefs.get(pref_key)}")
if target_email:
timeline = load_event_timeline(target_email)
publish_home_view(user_id, target_email, target_email, timeline.events)
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "✅ Updated settings",
},
)
except Exception as e:
print(f"Error processing toggle_pref: {e}")
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "❌ Failed to update settings. Please try again.",
},
)
return {"statusCode": 500, "body": "Error updating settings"}
return {"statusCode": 200, "body": "success"}
if action == "subscribe":
try:
if not user_id:
print("Error: Missing user_id for subscribe action")
raise ValueError("Missing user_id")
if not user_email:
user_email = get_user_email(user_id)
if not user_email:
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "❌ Unable to retrieve your email from Slack. Please provide it: `/praktika subscribe <email>`",
},
)
return {"statusCode": 200, "body": "Missing user email"}
# Load EventFeed from S3 using email
timeline = load_event_timeline(user_email)
# Add user_id to subscription list (supports multiple Slack users per email)
if subscriptions_s3_path:
# Remove user from previous subscription if switching emails
prev_email = FeedSubscription.find_user_subscription(
user_id, s3_path=subscriptions_s3_path
)
if prev_email and prev_email != user_email:
FeedSubscription.remove_user_id(
user_email=prev_email,
user_id=user_id,
s3_path=subscriptions_s3_path,
)
SUBSCRIPTION_CACHE.pop(prev_email, None)
print(
f"Removed user {user_id} from previous subscription: {prev_email}"
)
subscription = FeedSubscription.add_user_id(
user_email=user_email,
user_id=user_id,
s3_path=subscriptions_s3_path,
)
# Update cache with fresh subscription data
SUBSCRIPTION_CACHE[user_email] = subscription
print(f"Added subscription for user {user_id} to {user_email}")
else:
print(
"Warning: EVENT_FEED_S3_PATH not configured, skipping subscription save"
)
# Publish updated home view
publish_home_view(user_id, username, user_email, timeline.events)
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": f"✅ Subscribed to praktika feed for: `{user_email}`",
},
)
except Exception as e:
print(f"Error processing subscribe: {e}")
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "❌ Failed to subscribe to praktika feed. Please try again.",
},
)
return {"statusCode": 500, "body": "Error subscribing"}
elif action == "unsubscribe":
try:
if not user_id:
print("Error: Missing user_id for unsubscribe action")
raise ValueError("Missing user_id")
subscriptions_s3_path = os.environ.get("EVENT_FEED_S3_PATH", "")
if not subscriptions_s3_path:
print("Warning: EVENT_FEED_S3_PATH not configured, cannot unsubscribe")
raise ValueError("Configuration error")
# If user_email not provided, find it
target_email = user_email
if not target_email:
target_email = FeedSubscription.find_user_subscription(
user_id=user_id,
s3_path=subscriptions_s3_path,
)
if not target_email:
print(f"No subscription found for user {user_id}")
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "ℹ️ No active subscription found.",
},
)
return {"statusCode": 404, "body": "No subscription found"}
print(f"Found subscription for user {user_id}: {target_email}")
# Remove user from subscription
subscription = FeedSubscription.remove_user_id(
user_email=target_email,
user_id=user_id,
s3_path=subscriptions_s3_path,
)
# Update cache with new subscription data
SUBSCRIPTION_CACHE[target_email] = subscription
print(f"Removed user {user_id} from subscription to {target_email}")
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "✅ Unsubscribed from praktika feed",
},
)
except Exception as e:
print(f"Error processing unsubscribe: {e}")
if response_url:
post_to_response_url(
response_url,
{
"response_type": "ephemeral",
"text": "❌ Failed to unsubscribe from praktika feed. Please try again.",
},
)
return {"statusCode": 500, "body": "Error unsubscribing"}
elif action == "update":
# Get list of emails to update (supports both single username and list of emails)
emails = event.get("emails", [])
if not emails and username:
# Backward compatibility: support single username parameter
emails = [username]
if not emails:
print("Error: Missing emails for update action")
return {"statusCode": 400, "body": "Missing emails"}
print(f"Processing update for {len(emails)} email(s)")
# Process each email
for email in emails:
print(f"Processing update for {email}")
# Get subscribed user_ids (use cache if available)
subscription = _get_subscription_cached(email, subscriptions_s3_path)
subscribed_user_ids = subscription.user_ids if subscription else []
if subscribed_user_ids:
print(f"Using cached subscription for {email}")
else:
print(f"No subscriptions found for {email}, skipping notifications")
continue
if not subscribed_user_ids:
print(f"No subscriptions found for {email}, skipping notifications")
continue
# Load EventFeed from S3 using email
timeline = load_event_timeline(email)
newest_event = timeline.events[0] if timeline.events else None
newest_has_failures = False
if (
newest_event
and hasattr(newest_event, "result")
and newest_event.result
and newest_event.result.get("results")
):
newest_has_failures = any(
(r.get("status") or "").lower() in ["failure", "error", "fail"]
for r in newest_event.result.get("results", [])
)
# Publish updated home view to all subscribed users
for subscribed_user_id in subscribed_user_ids:
try:
publish_home_view(subscribed_user_id, email, email, timeline.events)
print(f"Published home view for user {subscribed_user_id}")
prefs = load_user_prefs(
subscribed_user_id, email, subscriptions_s3_path
)
if newest_event:
last_notified = prefs.get("last_notified_key")
# Backward compatibility: previously stored as a single string
if not isinstance(last_notified, dict):
last_notified = {}
prefs["last_notified_key"] = last_notified
ext = getattr(newest_event, "ext", {}) or {}
pr_number = ext.get("pr_number", 0) or 0
repo_name = ext.get("repo_name", "")
ext.get("pr_title", "")
sha = getattr(newest_event, "sha", "")
ci_status = (
getattr(newest_event, "ci_status", "") or ""
).lower()
should_notify_complete = getattr(
newest_event, "type", ""
) == "completed" and prefs.get("notify_on_complete")
should_notify_failure = (
not should_notify_complete
and newest_has_failures
and prefs.get("notify_on_failure")
)
for notify_type in ("failure", "complete"):
if notify_type == "failure" and not should_notify_failure:
continue
if notify_type == "complete" and not should_notify_complete:
continue
dedupe_key = f"{notify_type}:{email}:{sha}:{ci_status}:{pr_number}:{repo_name}"
if last_notified.get(notify_type) == dedupe_key:
continue
last_notified[notify_type] = dedupe_key
save_user_prefs(
user_id=subscribed_user_id,
user_email=email,
prefs=prefs,
s3_path=subscriptions_s3_path,
)
msg = _format_notification_text(newest_event, notify_type)
_post_dm(
user_id=subscribed_user_id,
user_email=email,
s3_path=subscriptions_s3_path,
text=msg,
)
except Exception as e:
print(f"Error publishing to user {subscribed_user_id}: {e}")
else:
print(f"Error: Unknown action '{action}'")
return {"statusCode": 400, "body": f"Unknown action: {action}"}
print(f"Completed processing {action} for user {user_id}")
return {"statusCode": 200, "body": "success"}