@misterhuydo/sentinel 1.4.66 → 1.4.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,331 +1,376 @@
1
- """
2
- notify.py — Best-effort Slack alerts from any Sentinel module.
3
-
4
- Uses the Slack Web API directly (no Bolt / Socket Mode required).
5
- Calls never raise — failures are logged and silently dropped.
6
- """
7
-
8
- import logging
9
- import re
10
- import time
11
-
12
- import requests
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- # ── Rate-limit / auth-failure detector ────────────────────────────────────────
17
-
18
- _RATE_LIMIT_RE = re.compile(
19
- r"rate.?limit|usage.?limit|too many requests|quota.?exceeded"
20
- r"|overloaded|credit.?balance|billing|529"
21
- r"|not.?authenticated|invalid.?api.?key|authentication.?fail"
22
- r"|claude\.ai subscription|pro.?plan|login required",
23
- re.IGNORECASE,
24
- )
25
-
26
-
27
- def is_rate_limited(text: str) -> bool:
28
- """Return True if the text contains a rate-limit or auth-failure signal."""
29
- return bool(_RATE_LIMIT_RE.search(text))
30
-
31
-
32
- # ── Circuit breaker ────────────────────────────────────────────────────────────
33
- #
34
- # Prevents alert storms when Claude is persistently rate-limited.
35
- # Each `source` string gets its own independent circuit:
36
- # CLOSED → normal; alerts pass through immediately
37
- # OPEN → suppressed; one re-alert every CIRCUIT_COOLDOWN_SECONDS
38
- #
39
- # On recovery (first non-rate-limited output after OPEN):
40
- # → post "resolved" to Slack, close the circuit
41
-
42
- CIRCUIT_COOLDOWN_SECONDS = 3600 # 1 h between repeat alerts while open
43
-
44
- # source → {opened_at, last_alerted_at, count}
45
- _circuits: dict[str, dict] = {}
46
-
47
-
48
- def get_circuit_status() -> dict:
49
- """
50
- Return a snapshot of all open circuits.
51
- Used by the `check_auth_status` Boss tool.
52
-
53
- Returns:
54
- { source: { state, opened_at, open_for_seconds, alert_count } }
55
- Only open circuits are included; an empty dict means everything is healthy.
56
- """
57
- now = time.time()
58
- return {
59
- src: {
60
- "state": "open",
61
- "opened_at": c["opened_at"],
62
- "open_for_seconds": int(now - c["opened_at"]),
63
- "alert_count": c["count"],
64
- }
65
- for src, c in _circuits.items()
66
- }
67
-
68
-
69
- def _open_or_repeat(bot_token: str, channel: str, source: str, output: str) -> None:
70
- """Open circuit on first hit; re-alert after cooldown if still failing."""
71
- now = time.time()
72
- circuit = _circuits.get(source)
73
-
74
- if circuit is None:
75
- # First occurrence — open and alert immediately
76
- _circuits[source] = {"opened_at": now, "last_alerted_at": now, "count": 1}
77
- logger.error("Circuit opened for %s: %s", source, output[:200])
78
- slack_alert(bot_token, channel, rate_limit_message(source, output))
79
- return
80
-
81
- circuit["count"] += 1
82
- elapsed = now - circuit["last_alerted_at"]
83
- if elapsed >= CIRCUIT_COOLDOWN_SECONDS:
84
- # Still failing after cooldown — remind admins once per hour
85
- circuit["last_alerted_at"] = now
86
- open_mins = int((now - circuit["opened_at"]) / 60)
87
- msg = (
88
- f":warning: *Sentinel — Claude usage/auth problem still active ({source})*\n"
89
- f"Still failing after {open_mins} minutes. Total occurrences: {circuit['count']}.\n"
90
- f"Last error:\n```{output.strip()[:300]}```\n"
91
- f"Run `check_auth_status` in Slack to see the full picture."
92
- )
93
- logger.error("Circuit still open for %s (count=%d)", source, circuit["count"])
94
- slack_alert(bot_token, channel, msg)
95
- # else: within cooldown window — suppress
96
-
97
-
98
- def _close_if_open(bot_token: str, channel: str, source: str) -> None:
99
- """If circuit was open, close it and post a recovery alert."""
100
- circuit = _circuits.pop(source, None)
101
- if circuit is None:
102
- return
103
- duration_mins = int((time.time() - circuit["opened_at"]) / 60)
104
- msg = (
105
- f":white_check_mark: *Sentinel — Claude auth restored ({source})*\n"
106
- f"Fixed after {duration_mins} min. Total failures during outage: {circuit['count']}."
107
- )
108
- logger.info("Circuit closed for %s after %d min, %d failures", source, duration_mins, circuit["count"])
109
- slack_alert(bot_token, channel, msg)
110
-
111
-
112
- def rate_limit_message(source: str, raw: str) -> str:
113
- """Produce a human-readable Slack alert for a rate-limit / auth event (first occurrence)."""
114
- snippet = raw.strip()[:300].replace("\n", " ")
115
- return (
116
- f":warning: *Sentinel — Claude usage/auth problem ({source})*\n"
117
- f"Claude returned an error that requires admin attention:\n"
118
- f"```{snippet}```\n"
119
- f"*What to check:*\n"
120
- f"• API key: verify `ANTHROPIC_API_KEY` in `sentinel.properties` is valid and has credit\n"
121
- f"• Claude Pro: run `claude login` on the server to refresh the OAuth session\n"
122
- f"• Both: Sentinel tries both methods — at least one must be working\n"
123
- f"Repeat alerts will be suppressed for 1 hour. "
124
- f"Run `check_auth_status` in Slack to see current state."
125
- )
126
-
127
-
128
- # ── Alert dispatcher ──────────────────────────────────────────────────────────
129
-
130
- def slack_alert(bot_token: str, channel: str, text: str) -> None:
131
- """
132
- Post a plain-text alert to a Slack channel.
133
- Best-effort: logs on failure, never raises.
134
- """
135
- if not bot_token or not channel:
136
- logger.debug("slack_alert: no token/channel configured — logging only: %s", text[:120])
137
- return
138
- try:
139
- resp = requests.post(
140
- "https://slack.com/api/chat.postMessage",
141
- headers={
142
- "Authorization": f"Bearer {bot_token}",
143
- "Content-Type": "application/json",
144
- },
145
- json={"channel": channel, "text": text},
146
- timeout=10,
147
- )
148
- data = resp.json()
149
- if not data.get("ok"):
150
- logger.warning("slack_alert: Slack API error: %s", data.get("error"))
151
- except Exception as exc:
152
- logger.warning("slack_alert: failed to post: %s", exc)
153
-
154
-
155
-
156
- def slack_dm(bot_token: str, user_id: str, text: str) -> None:
157
- """
158
- Send a direct message to a specific Slack user.
159
- Opens a DM channel via conversations.open, then posts.
160
- Best-effort: logs on failure, never raises.
161
- """
162
- if not bot_token or not user_id:
163
- logger.debug("slack_dm: no token/user_id — skipping DM")
164
- return
165
- try:
166
- resp = requests.post(
167
- "https://slack.com/api/conversations.open",
168
- headers={"Authorization": f"Bearer {bot_token}", "Content-Type": "application/json"},
169
- json={"users": user_id},
170
- timeout=10,
171
- )
172
- data = resp.json()
173
- if not data.get("ok"):
174
- logger.warning("slack_dm: conversations.open failed: %s", data.get("error"))
175
- return
176
- dm_channel = data["channel"]["id"]
177
- slack_alert(bot_token, dm_channel, text)
178
- except Exception as exc:
179
- logger.warning("slack_dm: failed to DM %s: %s", user_id, exc)
180
-
181
-
182
- def notify_fix_blocked(
183
- cfg,
184
- source: str,
185
- message: str,
186
- reason: str,
187
- repo_name: str = "",
188
- submitter_user_id: str = "",
189
- ) -> None:
190
- """
191
- Notify that a fix needs human intervention.
192
-
193
- - If submitter_user_id is known: DM that person directly.
194
- - Otherwise: @channel in the configured Slack channel.
195
- - Always: email admins via reporter.send_failure_notification.
196
- """
197
- short_reason = (reason or "Claude could not determine a safe fix.")[:600]
198
- repo_line = f"\n*Repo:* {repo_name}" if repo_name else ""
199
-
200
- slack_text = (
201
- f":hand: *Fix blocked — human intervention needed*\n"
202
- f"*Source:* {source}\n"
203
- f"*Issue:* {message[:200]}{repo_line}\n"
204
- f"*Reason:*\n{short_reason}"
205
- )
206
-
207
- if submitter_user_id:
208
- if getattr(cfg, "slack_dm_submitter", True):
209
- slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
210
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
211
- else:
212
- # No known submitter — broadcast to the whole channel
213
- slack_alert(
214
- cfg.slack_bot_token,
215
- cfg.slack_channel,
216
- f"<!channel> {slack_text}",
217
- )
218
-
219
- # Always email admins
220
- try:
221
- from .reporter import send_failure_notification
222
- send_failure_notification(cfg, {
223
- "source": source,
224
- "message": message,
225
- "repo_name": repo_name,
226
- "reason": f"Needs human intervention: {short_reason[:200]}",
227
- "body": reason,
228
- })
229
- except Exception as exc:
230
- logger.warning("notify_fix_blocked: email notification failed: %s", exc)
231
-
232
-
233
- def notify_tool_installing(cfg, tool: str, repo_name: str, source: str) -> None:
234
- """
235
- Post a brief Slack notice that Sentinel is auto-installing a whitelisted build tool.
236
- Called before the install begins so admins know what's happening.
237
- """
238
- repo_line = f" for *{repo_name}*" if repo_name else ""
239
- slack_alert(
240
- cfg.slack_bot_token,
241
- cfg.slack_channel,
242
- f":gear: *Auto-installing `{tool}`{repo_line}*\n"
243
- f"`{tool}` is required to run tests but not found on this server. "
244
- f"It's a known safe build tool — installing automatically. "
245
- f"Will retry the fix once done.",
246
- )
247
-
248
-
249
- def notify_missing_tool(
250
- cfg,
251
- tool: str,
252
- repo_name: str,
253
- source: str,
254
- submitter_user_id: str = "",
255
- ) -> None:
256
- """
257
- Notify admins that a build tool is missing on this server.
258
- Prompts them to ask Boss to install it.
259
- """
260
- repo_line = f" for *{repo_name}*" if repo_name else ""
261
- slack_text = (
262
- f":wrench: *Build tool missing{repo_line}*\n"
263
- f"*Source:* {source}\n"
264
- f"The fix was generated but tests couldn't run because `{tool}` is not installed on this server.\n\n"
265
- f"Ask me to install it:\n"
266
- f"> @Sentinel install {tool}\n\n"
267
- f"Once installed, re-raise the issue to apply the fix."
268
- )
269
- if submitter_user_id:
270
- if getattr(cfg, "slack_dm_submitter", True):
271
- slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
272
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
273
- else:
274
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
275
-
276
-
277
- def notify_fix_applied(
278
- cfg,
279
- source: str,
280
- message: str,
281
- repo_name: str,
282
- branch: str,
283
- pr_url: str,
284
- submitter_user_id: str = "",
285
- ) -> None:
286
- """
287
- DM the submitter (if known) that their issue was fixed.
288
- Falls back to posting in the Slack channel if no submitter.
289
- """
290
- repo_line = f" in *{repo_name}*" if repo_name else ""
291
- if pr_url:
292
- action_line = f":arrow_right: <{pr_url}|Review PR>"
293
- elif branch:
294
- action_line = f":arrow_right: Pushed to `{branch}`"
295
- else:
296
- action_line = ""
297
-
298
- slack_text = (
299
- f":white_check_mark: *Fix applied{repo_line}*\n"
300
- f"*Issue:* {message[:200]}\n"
301
- + (f"{action_line}\n" if action_line else "")
302
- ).rstrip()
303
-
304
- if submitter_user_id:
305
- if getattr(cfg, "slack_dm_submitter", True):
306
- slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
307
- channel_text = f"<@{submitter_user_id}> {slack_text}"
308
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, channel_text)
309
- else:
310
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, slack_text)
311
-
312
-
313
- def alert_if_rate_limited(
314
- bot_token: str,
315
- channel: str,
316
- source: str,
317
- output: str,
318
- ) -> bool:
319
- """
320
- Check output for rate-limit / auth signals and manage the circuit breaker.
321
-
322
- - Rate limited → open/keep-open circuit, alert (with cooldown suppression)
323
- - Not limited → close circuit if it was open (recovery alert), return False
324
-
325
- Returns True if a rate-limit signal was found.
326
- """
327
- if not is_rate_limited(output):
328
- _close_if_open(bot_token, channel, source)
329
- return False
330
- _open_or_repeat(bot_token, channel, source, output)
331
- return True
1
+ """
2
+ notify.py — Best-effort Slack alerts from any Sentinel module.
3
+
4
+ Uses the Slack Web API directly (no Bolt / Socket Mode required).
5
+ Calls never raise — failures are logged and silently dropped.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ import time
11
+
12
+ import requests
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ── Rate-limit / auth-failure detector ────────────────────────────────────────
17
+
18
+ _RATE_LIMIT_RE = re.compile(
19
+ r"rate.?limit|usage.?limit|too many requests|quota.?exceeded"
20
+ r"|overloaded|credit.?balance|billing|529"
21
+ r"|not.?authenticated|invalid.?api.?key|authentication.?fail"
22
+ r"|claude\.ai subscription|pro.?plan|login required",
23
+ re.IGNORECASE,
24
+ )
25
+
26
+
27
+ def is_rate_limited(text: str) -> bool:
28
+ """Return True if the text contains a rate-limit or auth-failure signal."""
29
+ return bool(_RATE_LIMIT_RE.search(text))
30
+
31
+
32
+ # ── Circuit breaker ────────────────────────────────────────────────────────────
33
+ #
34
+ # Prevents alert storms when Claude is persistently rate-limited.
35
+ # Each `source` string gets its own independent circuit:
36
+ # CLOSED → normal; alerts pass through immediately
37
+ # OPEN → suppressed; one re-alert every CIRCUIT_COOLDOWN_SECONDS
38
+ #
39
+ # On recovery (first non-rate-limited output after OPEN):
40
+ # → post "resolved" to Slack, close the circuit
41
+
42
+ CIRCUIT_COOLDOWN_SECONDS = 3600 # 1 h between repeat alerts while open
43
+
44
+ # source → {opened_at, last_alerted_at, count}
45
+ _circuits: dict[str, dict] = {}
46
+
47
+
48
+ def get_circuit_status() -> dict:
49
+ """
50
+ Return a snapshot of all open circuits.
51
+ Used by the `check_auth_status` Boss tool.
52
+
53
+ Returns:
54
+ { source: { state, opened_at, open_for_seconds, alert_count } }
55
+ Only open circuits are included; an empty dict means everything is healthy.
56
+ """
57
+ now = time.time()
58
+ return {
59
+ src: {
60
+ "state": "open",
61
+ "opened_at": c["opened_at"],
62
+ "open_for_seconds": int(now - c["opened_at"]),
63
+ "alert_count": c["count"],
64
+ }
65
+ for src, c in _circuits.items()
66
+ }
67
+
68
+
69
+ def _open_or_repeat(bot_token: str, channel: str, source: str, output: str) -> None:
70
+ """Open circuit on first hit; re-alert after cooldown if still failing."""
71
+ now = time.time()
72
+ circuit = _circuits.get(source)
73
+
74
+ if circuit is None:
75
+ # First occurrence — open and alert immediately
76
+ _circuits[source] = {"opened_at": now, "last_alerted_at": now, "count": 1}
77
+ logger.error("Circuit opened for %s: %s", source, output[:200])
78
+ slack_alert(bot_token, channel, rate_limit_message(source, output))
79
+ return
80
+
81
+ circuit["count"] += 1
82
+ elapsed = now - circuit["last_alerted_at"]
83
+ if elapsed >= CIRCUIT_COOLDOWN_SECONDS:
84
+ # Still failing after cooldown — remind admins once per hour
85
+ circuit["last_alerted_at"] = now
86
+ open_mins = int((now - circuit["opened_at"]) / 60)
87
+ msg = (
88
+ f":warning: *Sentinel — Claude usage/auth problem still active ({source})*\n"
89
+ f"Still failing after {open_mins} minutes. Total occurrences: {circuit['count']}.\n"
90
+ f"Last error:\n```{output.strip()[:300]}```\n"
91
+ f"Run `check_auth_status` in Slack to see the full picture."
92
+ )
93
+ logger.error("Circuit still open for %s (count=%d)", source, circuit["count"])
94
+ slack_alert(bot_token, channel, msg)
95
+ # else: within cooldown window — suppress
96
+
97
+
98
+ def _close_if_open(bot_token: str, channel: str, source: str) -> None:
99
+ """If circuit was open, close it and post a recovery alert."""
100
+ circuit = _circuits.pop(source, None)
101
+ if circuit is None:
102
+ return
103
+ duration_mins = int((time.time() - circuit["opened_at"]) / 60)
104
+ msg = (
105
+ f":white_check_mark: *Sentinel — Claude auth restored ({source})*\n"
106
+ f"Fixed after {duration_mins} min. Total failures during outage: {circuit['count']}."
107
+ )
108
+ logger.info("Circuit closed for %s after %d min, %d failures", source, duration_mins, circuit["count"])
109
+ slack_alert(bot_token, channel, msg)
110
+
111
+
112
+ def rate_limit_message(source: str, raw: str) -> str:
113
+ """Produce a human-readable Slack alert for a rate-limit / auth event (first occurrence)."""
114
+ snippet = raw.strip()[:300].replace("\n", " ")
115
+ return (
116
+ f":warning: *Sentinel — Claude usage/auth problem ({source})*\n"
117
+ f"Claude returned an error that requires admin attention:\n"
118
+ f"```{snippet}```\n"
119
+ f"*What to check:*\n"
120
+ f"• API key: verify `ANTHROPIC_API_KEY` in `sentinel.properties` is valid and has credit\n"
121
+ f"• Claude Pro: run `claude login` on the server to refresh the OAuth session\n"
122
+ f"• Both: Sentinel tries both methods — at least one must be working\n"
123
+ f"Repeat alerts will be suppressed for 1 hour. "
124
+ f"Run `check_auth_status` in Slack to see current state."
125
+ )
126
+
127
+
128
+ # ── Alert dispatcher ──────────────────────────────────────────────────────────
129
+
130
+ def slack_alert(bot_token: str, channel: str, text: str) -> None:
131
+ """
132
+ Post a plain-text alert to a Slack channel.
133
+ Best-effort: logs on failure, never raises.
134
+ """
135
+ if not bot_token or not channel:
136
+ logger.debug("slack_alert: no token/channel configured — logging only: %s", text[:120])
137
+ return
138
+ try:
139
+ resp = requests.post(
140
+ "https://slack.com/api/chat.postMessage",
141
+ headers={
142
+ "Authorization": f"Bearer {bot_token}",
143
+ "Content-Type": "application/json",
144
+ },
145
+ json={"channel": channel, "text": text},
146
+ timeout=10,
147
+ )
148
+ data = resp.json()
149
+ if not data.get("ok"):
150
+ logger.warning("slack_alert: Slack API error: %s", data.get("error"))
151
+ except Exception as exc:
152
+ logger.warning("slack_alert: failed to post: %s", exc)
153
+
154
+
155
+
156
+ def slack_dm(bot_token: str, user_id: str, text: str) -> None:
157
+ """
158
+ Send a direct message to a specific Slack user.
159
+ Opens a DM channel via conversations.open, then posts.
160
+ Best-effort: logs on failure, never raises.
161
+ """
162
+ if not bot_token or not user_id:
163
+ logger.debug("slack_dm: no token/user_id — skipping DM")
164
+ return
165
+ try:
166
+ resp = requests.post(
167
+ "https://slack.com/api/conversations.open",
168
+ headers={"Authorization": f"Bearer {bot_token}", "Content-Type": "application/json"},
169
+ json={"users": user_id},
170
+ timeout=10,
171
+ )
172
+ data = resp.json()
173
+ if not data.get("ok"):
174
+ logger.warning("slack_dm: conversations.open failed: %s", data.get("error"))
175
+ return
176
+ dm_channel = data["channel"]["id"]
177
+ slack_alert(bot_token, dm_channel, text)
178
+ except Exception as exc:
179
+ logger.warning("slack_dm: failed to DM %s: %s", user_id, exc)
180
+
181
+
182
+ def notify_fix_blocked(
183
+ cfg,
184
+ source: str,
185
+ message: str,
186
+ reason: str,
187
+ repo_name: str = "",
188
+ submitter_user_id: str = "",
189
+ ) -> None:
190
+ """
191
+ Notify that a fix needs human intervention.
192
+
193
+ - If submitter_user_id is known: DM that person directly.
194
+ - Otherwise: @channel in the configured Slack channel.
195
+ - Always: email admins via reporter.send_failure_notification.
196
+ """
197
+ short_reason = (reason or "Claude could not determine a safe fix.")[:600]
198
+ repo_line = f"\n*Repo:* {repo_name}" if repo_name else ""
199
+
200
+ slack_text = (
201
+ f":hand: *Fix blocked — human intervention needed*\n"
202
+ f"*Source:* {source}\n"
203
+ f"*Issue:* {message[:200]}{repo_line}\n"
204
+ f"*Reason:*\n{short_reason}"
205
+ )
206
+
207
+ if submitter_user_id:
208
+ if getattr(cfg, "slack_dm_submitter", True):
209
+ slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
210
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
211
+ else:
212
+ # No known submitter — broadcast to the whole channel
213
+ slack_alert(
214
+ cfg.slack_bot_token,
215
+ cfg.slack_channel,
216
+ f"<!channel> {slack_text}",
217
+ )
218
+
219
+ # Always email admins
220
+ try:
221
+ from .reporter import send_failure_notification
222
+ send_failure_notification(cfg, {
223
+ "source": source,
224
+ "message": message,
225
+ "repo_name": repo_name,
226
+ "reason": f"Needs human intervention: {short_reason[:200]}",
227
+ "body": reason,
228
+ })
229
+ except Exception as exc:
230
+ logger.warning("notify_fix_blocked: email notification failed: %s", exc)
231
+
232
+
233
+ def notify_tool_installing(cfg, tool: str, repo_name: str, source: str) -> None:
234
+ """
235
+ Post a brief Slack notice that Sentinel is auto-installing a whitelisted build tool.
236
+ Called before the install begins so admins know what's happening.
237
+ """
238
+ repo_line = f" for *{repo_name}*" if repo_name else ""
239
+ slack_alert(
240
+ cfg.slack_bot_token,
241
+ cfg.slack_channel,
242
+ f":gear: *Auto-installing `{tool}`{repo_line}*\n"
243
+ f"`{tool}` is required to run tests but not found on this server. "
244
+ f"It's a known safe build tool — installing automatically. "
245
+ f"Will retry the fix once done.",
246
+ )
247
+
248
+
249
+ def notify_missing_tool(
250
+ cfg,
251
+ tool: str,
252
+ repo_name: str,
253
+ source: str,
254
+ submitter_user_id: str = "",
255
+ ) -> None:
256
+ """
257
+ Notify admins that a build tool is missing on this server.
258
+ Prompts them to ask Boss to install it.
259
+ """
260
+ repo_line = f" for *{repo_name}*" if repo_name else ""
261
+ slack_text = (
262
+ f":wrench: *Build tool missing{repo_line}*\n"
263
+ f"*Source:* {source}\n"
264
+ f"The fix was generated but tests couldn't run because `{tool}` is not installed on this server.\n\n"
265
+ f"Ask me to install it:\n"
266
+ f"> @Sentinel install {tool}\n\n"
267
+ f"Once installed, re-raise the issue to apply the fix."
268
+ )
269
+ if submitter_user_id:
270
+ if getattr(cfg, "slack_dm_submitter", True):
271
+ slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
272
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
273
+ else:
274
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
275
+
276
+
277
+ def notify_fix_applied(
278
+ cfg,
279
+ source: str,
280
+ message: str,
281
+ repo_name: str,
282
+ branch: str,
283
+ pr_url: str,
284
+ submitter_user_id: str = "",
285
+ ) -> None:
286
+ """
287
+ DM the submitter (if known) that their issue was fixed.
288
+ Falls back to posting in the Slack channel if no submitter.
289
+ """
290
+ repo_line = f" in *{repo_name}*" if repo_name else ""
291
+ if pr_url:
292
+ action_line = f":arrow_right: <{pr_url}|Review PR>"
293
+ elif branch:
294
+ action_line = f":arrow_right: Pushed to `{branch}`"
295
+ else:
296
+ action_line = ""
297
+
298
+ slack_text = (
299
+ f":white_check_mark: *Fix applied{repo_line}*\n"
300
+ f"*Issue:* {message[:200]}\n"
301
+ + (f"{action_line}\n" if action_line else "")
302
+ ).rstrip()
303
+
304
+ if submitter_user_id:
305
+ if getattr(cfg, "slack_dm_submitter", True):
306
+ slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
307
+ channel_text = f"<@{submitter_user_id}> {slack_text}"
308
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, channel_text)
309
+ else:
310
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, slack_text)
311
+
312
+
313
+ def notify_cascade_started(
314
+ cfg,
315
+ artifact_id: str,
316
+ new_version: str,
317
+ target_repos: list,
318
+ user_id: str = "",
319
+ ) -> None:
320
+ """Notify that a dependency cascade is starting."""
321
+ repo_list = ", ".join(f"`{r}`" for r in target_repos) if target_repos else "none found"
322
+ text = (
323
+ f":arrows_counterclockwise: *Dependency cascade started*\n"
324
+ f"Updating `{artifact_id}` \u2192 `{new_version}` in {len(target_repos)} repo(s): {repo_list}"
325
+ )
326
+ if user_id:
327
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{user_id}> {text}")
328
+ else:
329
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, text)
330
+
331
+
332
+ def notify_cascade_result(
333
+ cfg,
334
+ artifact_id: str,
335
+ new_version: str,
336
+ results: list,
337
+ user_id: str = "",
338
+ ) -> None:
339
+ """Notify success/failure for each repo in a dependency cascade."""
340
+ lines = []
341
+ for r in results:
342
+ if r.success:
343
+ action = f"<{r.pr_url}|PR opened>" if r.pr_url else f"pushed to `{r.branch}`"
344
+ lines.append(f":white_check_mark: *{r.repo_name}* \u2014 {r.old_version} \u2192 {new_version} ({action})")
345
+ else:
346
+ lines.append(f":x: *{r.repo_name}* \u2014 failed: {r.error}")
347
+
348
+ ok = sum(1 for r in results if r.success)
349
+ fail = len(results) - ok
350
+ summary = f":package: *Cascade complete* \u2014 `{artifact_id}` `{new_version}`: {ok} updated, {fail} failed"
351
+ text = summary + "\n" + "\n".join(lines)
352
+ if user_id:
353
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{user_id}> {text}")
354
+ else:
355
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, text)
356
+
357
+
358
+ def alert_if_rate_limited(
359
+ bot_token: str,
360
+ channel: str,
361
+ source: str,
362
+ output: str,
363
+ ) -> bool:
364
+ """
365
+ Check output for rate-limit / auth signals and manage the circuit breaker.
366
+
367
+ - Rate limited → open/keep-open circuit, alert (with cooldown suppression)
368
+ - Not limited → close circuit if it was open (recovery alert), return False
369
+
370
+ Returns True if a rate-limit signal was found.
371
+ """
372
+ if not is_rate_limited(output):
373
+ _close_if_open(bot_token, channel, source)
374
+ return False
375
+ _open_or_repeat(bot_token, channel, source, output)
376
+ return True