@m13v/s4l 1.7.0 → 1.7.1-rc.1
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.
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +26 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/sentry_digest.py +303 -0
- package/skill/sentry-digest.sh +19 -0
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.1-rc.1",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -309,6 +309,15 @@ AUTOPILOT_RUNNING_STALL_SECONDS = 900
|
|
|
309
309
|
# healthy single drain (the worker itself dies at ~2 min today).
|
|
310
310
|
DRAFT_STUCK_SECONDS = 300
|
|
311
311
|
|
|
312
|
+
# A poll can read a transient "not stuck" moment (e.g. the activity file
|
|
313
|
+
# between one dying worker attempt and the next claim on the same aged job)
|
|
314
|
+
# even though the underlying episode never really cleared. Without this, each
|
|
315
|
+
# such blip resets _stall_notified and the very next poll re-fires the Sentry
|
|
316
|
+
# alert, producing a burst of duplicate events for one continuous episode
|
|
317
|
+
# (observed: 5 events in 40s, 2026-07-07). Require attention to read false for
|
|
318
|
+
# this long, continuously, before treating the episode as over.
|
|
319
|
+
ATTENTION_CLEAR_COOLDOWN_SECONDS = 60
|
|
320
|
+
|
|
312
321
|
# Unattended-review watchdog. A card stack is open with pending drafts and the
|
|
313
322
|
# user has not decided or clicked anything on it for REVIEW_UNATTENDED_SECONDS:
|
|
314
323
|
# treat that as "the user is not seeing this window" regardless of what AppKit
|
|
@@ -501,6 +510,11 @@ class S4LMenuBar(rumps.App):
|
|
|
501
510
|
# One-shot guard so the "autopilot not running" notification fires once per
|
|
502
511
|
# stall episode, not every poll. Reset when the stall clears.
|
|
503
512
|
self._stall_notified = False
|
|
513
|
+
# Wall-clock time (time.time()) attention first read false since the last
|
|
514
|
+
# true reading, or None while attention is true / already cleared. Debounces
|
|
515
|
+
# _stall_notified's reset against ATTENTION_CLEAR_COOLDOWN_SECONDS so a
|
|
516
|
+
# transient blip doesn't end the episode early and cause a re-fire burst.
|
|
517
|
+
self._attention_clear_since = None
|
|
504
518
|
# Cached stall flag (set each _tick) so the 1s activity poll can suppress a
|
|
505
519
|
# stale "drafting" spinner that would otherwise mask the ⚠ in the title.
|
|
506
520
|
self._stalled = False
|
|
@@ -2138,6 +2152,8 @@ class S4LMenuBar(rumps.App):
|
|
|
2138
2152
|
)
|
|
2139
2153
|
self._last_blocker_code = blocker_code
|
|
2140
2154
|
# Notify once per episode (the draft schedule isn't running for this account).
|
|
2155
|
+
if attention:
|
|
2156
|
+
self._attention_clear_since = None
|
|
2141
2157
|
if attention and not self._stall_notified:
|
|
2142
2158
|
# Fleet-wide telemetry: the draft autopilot needs attention on THIS
|
|
2143
2159
|
# install (orphaned by an account switch, disabled, rate-limited, or a
|
|
@@ -2151,7 +2167,10 @@ class S4LMenuBar(rumps.App):
|
|
|
2151
2167
|
_capture_msg(
|
|
2152
2168
|
f"S4L draft autopilot needs attention: {_reason}",
|
|
2153
2169
|
level="warning",
|
|
2154
|
-
_extra={
|
|
2170
|
+
_extra={
|
|
2171
|
+
"scheduled_tasks": _registry_summary_for_capture(),
|
|
2172
|
+
"stall_label": self._stall_reason_info[1] or None,
|
|
2173
|
+
},
|
|
2155
2174
|
phase="draft_schedule",
|
|
2156
2175
|
reason=_reason,
|
|
2157
2176
|
schedule_state=str(schedule_state),
|
|
@@ -2182,8 +2201,12 @@ class S4LMenuBar(rumps.App):
|
|
|
2182
2201
|
"accounts clears them). Open the S4L menu → “Set up draft schedule”.",
|
|
2183
2202
|
)
|
|
2184
2203
|
self._stall_notified = True
|
|
2185
|
-
elif not attention:
|
|
2186
|
-
self.
|
|
2204
|
+
elif not attention and self._stall_notified:
|
|
2205
|
+
if self._attention_clear_since is None:
|
|
2206
|
+
self._attention_clear_since = time.time()
|
|
2207
|
+
elif time.time() - self._attention_clear_since >= ATTENTION_CLEAR_COOLDOWN_SECONDS:
|
|
2208
|
+
self._stall_notified = False
|
|
2209
|
+
self._attention_clear_since = None
|
|
2187
2210
|
|
|
2188
2211
|
# Single-source update signal: copy the snapshot's result (snapshot.py
|
|
2189
2212
|
# _latest_published: GitHub releases/latest first, npm fallback; semver >,
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.1-rc.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sentry issue digest for the s4l Sentry project (org mediar-n5).
|
|
3
|
+
|
|
4
|
+
Replaces the raw per-issue Sentry alert email. That alert rule
|
|
5
|
+
(mediar-n5/s4l rule id 17212931) fired on every first-seen issue and every
|
|
6
|
+
high-priority mark regardless of severity, so it also emailed on level=warning
|
|
7
|
+
menubar operational pings ("draft_stuck", "missing", "rate_limited", "review
|
|
8
|
+
card unattended"). Those still exist in Sentry, still get investigated
|
|
9
|
+
on-demand via the "Debugging a customer install" playbook (see CLAUDE.md),
|
|
10
|
+
they just no longer push a raw email. This script is scoped to level:error and
|
|
11
|
+
level:fatal only ("critical Sentry issues" per user instruction 2026-07-07):
|
|
12
|
+
real Python exceptions and pipeline failures, not the synthetic warning pings.
|
|
13
|
+
|
|
14
|
+
Impact ranking uses distinct install_id count, not Sentry's built-in
|
|
15
|
+
userCount, because s4l events are tagged per-install (install_id), not
|
|
16
|
+
per-Sentry-user (userCount is 0 across the board for this project).
|
|
17
|
+
|
|
18
|
+
Idempotency / noise control: a JSON ledger at scripts/state/sentry_digest_ledger.json
|
|
19
|
+
tracks last-seen event/install counts per issue. A digest email is only sent
|
|
20
|
+
when something is NEW (not in the ledger) or GROWING (event count up 20%+ and
|
|
21
|
+
by at least 5 events since the ledger snapshot). First run baselines every
|
|
22
|
+
open critical issue without flagging all of them as new.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python3 scripts/sentry_digest.py # normal run (used by launchd)
|
|
26
|
+
python3 scripts/sentry_digest.py --dry-run # print what would happen, no email, no ledger write
|
|
27
|
+
|
|
28
|
+
Patterned after strike_alert.py: same Gmail token, same dash-scrubbing,
|
|
29
|
+
default recipient i@m13v.com.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import base64
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.parse
|
|
40
|
+
import urllib.request
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from email.mime.text import MIMEText
|
|
43
|
+
|
|
44
|
+
SENTRY_ORG = "mediar-n5"
|
|
45
|
+
SENTRY_PROJECT = "s4l"
|
|
46
|
+
SENTRY_PROJECT_ID = "4511598804336640"
|
|
47
|
+
SENTRY_API = "https://sentry.io/api/0"
|
|
48
|
+
|
|
49
|
+
# "Critical Sentry issues" = level:error or level:fatal. Excludes level:warning
|
|
50
|
+
# (the synthetic menubar/autopilot signal pings), which are a different kind
|
|
51
|
+
# of event and already have their own surfacing (menubar UI, dashboard,
|
|
52
|
+
# on-demand Sentry queries during customer debugging).
|
|
53
|
+
ISSUE_QUERY = "is:unresolved level:[error,fatal]"
|
|
54
|
+
|
|
55
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
56
|
+
STATE_DIR = os.path.join(REPO_DIR, "scripts", "state")
|
|
57
|
+
LEDGER_PATH = os.path.join(STATE_DIR, "sentry_digest_ledger.json")
|
|
58
|
+
|
|
59
|
+
GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
|
|
60
|
+
GMAIL_SCOPES = ["https://mail.google.com/"]
|
|
61
|
+
NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
|
|
62
|
+
|
|
63
|
+
# Growth threshold: an issue re-alerts if event count grew by at least this
|
|
64
|
+
# many events AND by at least this relative fraction since the ledger snapshot.
|
|
65
|
+
GROWTH_MIN_DELTA = 5
|
|
66
|
+
GROWTH_MIN_RATIO = 1.2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scrub_dashes(s):
|
|
70
|
+
if not s:
|
|
71
|
+
return s
|
|
72
|
+
return s.replace("—", ",").replace("–", ",")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sentry_token():
|
|
76
|
+
env_token = os.environ.get("SENTRY_AUTH_TOKEN")
|
|
77
|
+
if env_token:
|
|
78
|
+
return env_token
|
|
79
|
+
try:
|
|
80
|
+
out = subprocess.run(
|
|
81
|
+
["security", "find-generic-password", "-s", "sentry-auth-token", "-w"],
|
|
82
|
+
capture_output=True, text=True, timeout=10,
|
|
83
|
+
)
|
|
84
|
+
if out.returncode == 0:
|
|
85
|
+
return out.stdout.strip()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sentry_get(path, token):
|
|
92
|
+
req = urllib.request.Request(f"{SENTRY_API}{path}", headers={"Authorization": f"Bearer {token}"})
|
|
93
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
94
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_critical_issues(token):
|
|
98
|
+
path = (
|
|
99
|
+
f"/projects/{SENTRY_ORG}/{SENTRY_PROJECT}/issues/"
|
|
100
|
+
f"?query={urllib.parse.quote(ISSUE_QUERY)}&statsPeriod=24h&sort=freq&limit=100"
|
|
101
|
+
)
|
|
102
|
+
return _sentry_get(path, token)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fetch_install_impact(issue_id, token):
|
|
106
|
+
"""Distinct install_id count + top installs for one issue. Returns (count, top)."""
|
|
107
|
+
try:
|
|
108
|
+
data = _sentry_get(f"/issues/{issue_id}/tags/install_id/", token)
|
|
109
|
+
return data.get("uniqueValues", 0), data.get("topValues", [])
|
|
110
|
+
except urllib.error.HTTPError as e:
|
|
111
|
+
if e.code == 404:
|
|
112
|
+
return 0, [] # tag not present on any event for this issue
|
|
113
|
+
raise
|
|
114
|
+
except Exception:
|
|
115
|
+
return 0, []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_ledger():
|
|
119
|
+
if not os.path.exists(LEDGER_PATH):
|
|
120
|
+
return {"version": 1, "lastUpdated": None, "issues": {}}
|
|
121
|
+
try:
|
|
122
|
+
with open(LEDGER_PATH) as f:
|
|
123
|
+
return json.load(f)
|
|
124
|
+
except Exception:
|
|
125
|
+
return {"version": 1, "lastUpdated": None, "issues": {}}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def save_ledger(ledger):
|
|
129
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
130
|
+
tmp = LEDGER_PATH + ".tmp"
|
|
131
|
+
with open(tmp, "w") as f:
|
|
132
|
+
json.dump(ledger, f, indent=2)
|
|
133
|
+
os.replace(tmp, LEDGER_PATH)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def issue_link(short_id):
|
|
137
|
+
return f"https://mediar-n5.sentry.io/issues/?project={SENTRY_PROJECT_ID}&query={short_id}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def gmail_service():
|
|
141
|
+
from google.auth.transport.requests import Request
|
|
142
|
+
from google.oauth2.credentials import Credentials
|
|
143
|
+
from googleapiclient.discovery import build
|
|
144
|
+
|
|
145
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
146
|
+
if creds.expired and creds.refresh_token:
|
|
147
|
+
creds.refresh(Request())
|
|
148
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
149
|
+
f.write(creds.to_json())
|
|
150
|
+
return build("gmail", "v1", credentials=creds)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def send_email(service, to_addr, subject, html_body):
|
|
154
|
+
msg = MIMEText(html_body, "html")
|
|
155
|
+
msg["to"] = to_addr
|
|
156
|
+
msg["subject"] = _scrub_dashes(subject)
|
|
157
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
158
|
+
result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
159
|
+
return result["id"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_rows_html(rows, growth=False):
|
|
163
|
+
cells = []
|
|
164
|
+
for r in rows:
|
|
165
|
+
link = issue_link(r["shortId"])
|
|
166
|
+
if growth:
|
|
167
|
+
cells.append(
|
|
168
|
+
f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
|
|
169
|
+
f"<td>{r['prevCount']} → {r['count']}</td>"
|
|
170
|
+
f"<td>{r['prevInstalls']} → {r['installs']}</td></tr>"
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
cells.append(
|
|
174
|
+
f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
|
|
175
|
+
f"<td>{r['count']}</td><td>{r['installs']}</td></tr>"
|
|
176
|
+
)
|
|
177
|
+
return "".join(cells)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_html(new_rows, growing_rows, first_run, total_open):
|
|
181
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
182
|
+
sections = [f"<p><b>Critical Sentry issues (level:error/fatal), s4l project.</b> "
|
|
183
|
+
f"Open total: {total_open}. Generated {today}.</p>"]
|
|
184
|
+
|
|
185
|
+
if first_run:
|
|
186
|
+
sections.append("<p>First run: baselining every open critical issue. No investigation, "
|
|
187
|
+
"just a snapshot. Future runs only flag NEW or GROWING issues.</p>")
|
|
188
|
+
|
|
189
|
+
if new_rows and not first_run:
|
|
190
|
+
sections.append("<h3>New issues</h3><table border='1' cellpadding='6' "
|
|
191
|
+
"style='border-collapse:collapse'><tr><th>Issue</th><th>Events</th>"
|
|
192
|
+
"<th>Installs</th></tr>" + build_rows_html(new_rows) + "</table>")
|
|
193
|
+
|
|
194
|
+
if growing_rows:
|
|
195
|
+
sections.append("<h3>Growing issues</h3><table border='1' cellpadding='6' "
|
|
196
|
+
"style='border-collapse:collapse'><tr><th>Issue</th>"
|
|
197
|
+
"<th>Events (was → now)</th><th>Installs (was → now)</th></tr>"
|
|
198
|
+
+ build_rows_html(growing_rows, growth=True) + "</table>")
|
|
199
|
+
|
|
200
|
+
if first_run:
|
|
201
|
+
top = sorted(new_rows, key=lambda r: -r["installs"])[:10]
|
|
202
|
+
sections.append("<h3>Top 10 by installs affected (baseline snapshot)</h3>"
|
|
203
|
+
"<table border='1' cellpadding='6' style='border-collapse:collapse'>"
|
|
204
|
+
"<tr><th>Issue</th><th>Events</th><th>Installs</th></tr>"
|
|
205
|
+
+ build_rows_html(top) + "</table>")
|
|
206
|
+
|
|
207
|
+
sections.append("<p style='color:#888;font-size:12px'>Ranked by distinct install_id count, "
|
|
208
|
+
"not Sentry's userCount (unset for this project). level:warning menubar "
|
|
209
|
+
"signals are excluded; they're a different kind of event and still queryable "
|
|
210
|
+
"in Sentry directly during customer debugging.</p>")
|
|
211
|
+
return "<div style='font-family:sans-serif;max-width:800px'>" + "".join(sections) + "</div>"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main():
|
|
215
|
+
parser = argparse.ArgumentParser()
|
|
216
|
+
parser.add_argument("--dry-run", action="store_true", help="Print plan, do not send email or write ledger.")
|
|
217
|
+
args = parser.parse_args()
|
|
218
|
+
|
|
219
|
+
token = _sentry_token()
|
|
220
|
+
if not token:
|
|
221
|
+
print("ERROR: no Sentry auth token (checked SENTRY_AUTH_TOKEN env and keychain sentry-auth-token)", file=sys.stderr)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
issues = fetch_critical_issues(token)
|
|
225
|
+
if not isinstance(issues, list):
|
|
226
|
+
print(f"ERROR: unexpected Sentry response: {issues}", file=sys.stderr)
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
ledger = load_ledger()
|
|
230
|
+
known = ledger.get("issues", {})
|
|
231
|
+
first_run = len(known) == 0
|
|
232
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
233
|
+
|
|
234
|
+
new_rows, growing_rows = [], []
|
|
235
|
+
updated_issues = dict(known)
|
|
236
|
+
|
|
237
|
+
for issue in issues:
|
|
238
|
+
short_id = issue["shortId"]
|
|
239
|
+
count = int(issue.get("count", 0))
|
|
240
|
+
installs, _top = fetch_install_impact(issue["id"], token)
|
|
241
|
+
prev = known.get(short_id)
|
|
242
|
+
|
|
243
|
+
if prev is None:
|
|
244
|
+
new_rows.append({"shortId": short_id, "title": issue["title"][:90], "count": count, "installs": installs})
|
|
245
|
+
else:
|
|
246
|
+
prev_count = int(prev.get("lastEventCount", 0))
|
|
247
|
+
if not first_run and count - prev_count >= GROWTH_MIN_DELTA and prev_count > 0 and count >= prev_count * GROWTH_MIN_RATIO:
|
|
248
|
+
growing_rows.append({
|
|
249
|
+
"shortId": short_id, "title": issue["title"][:90],
|
|
250
|
+
"count": count, "prevCount": prev_count,
|
|
251
|
+
"installs": installs, "prevInstalls": prev.get("lastInstallCount", 0),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
updated_issues[short_id] = {
|
|
255
|
+
"title": issue["title"][:200],
|
|
256
|
+
"lastEventCount": count,
|
|
257
|
+
"lastInstallCount": installs,
|
|
258
|
+
"firstSeenRun": (prev or {}).get("firstSeenRun", now_iso),
|
|
259
|
+
"lastSeenRun": now_iso,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
print(f"firstRun={first_run} newCount={len(new_rows)} growingCount={len(growing_rows)} totalOpen={len(issues)}")
|
|
263
|
+
|
|
264
|
+
should_email = first_run or new_rows or growing_rows
|
|
265
|
+
if not should_email:
|
|
266
|
+
print("Nothing new or growing. No email.")
|
|
267
|
+
if not args.dry_run:
|
|
268
|
+
ledger["issues"] = updated_issues
|
|
269
|
+
ledger["lastUpdated"] = now_iso
|
|
270
|
+
save_ledger(ledger)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if first_run:
|
|
274
|
+
subject = f"[Sentry] s4l critical-issue digest live: {len(issues)} baselined"
|
|
275
|
+
elif new_rows and growing_rows:
|
|
276
|
+
subject = f"[Sentry] s4l: {len(new_rows)} new, {len(growing_rows)} growing critical issue(s)"
|
|
277
|
+
elif new_rows:
|
|
278
|
+
top_new = max(new_rows, key=lambda r: r["installs"])
|
|
279
|
+
subject = f"[Sentry] s4l: new critical issue, {top_new['shortId']} ({top_new['installs']} installs)"
|
|
280
|
+
else:
|
|
281
|
+
top_grow = max(growing_rows, key=lambda r: r["installs"])
|
|
282
|
+
subject = f"[Sentry] s4l: {top_grow['shortId']} growing ({top_grow['prevCount']} -> {top_grow['count']} events)"
|
|
283
|
+
|
|
284
|
+
html = build_html(new_rows, growing_rows, first_run, len(issues))
|
|
285
|
+
|
|
286
|
+
print(f"Subject: {subject}")
|
|
287
|
+
if args.dry_run:
|
|
288
|
+
print("--dry-run: not sending email, not writing ledger.")
|
|
289
|
+
print(html)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
service = gmail_service()
|
|
293
|
+
msg_id = send_email(service, NOTIFICATION_EMAIL, subject, html)
|
|
294
|
+
print(f"Email sent: {msg_id}")
|
|
295
|
+
|
|
296
|
+
ledger["issues"] = updated_issues
|
|
297
|
+
ledger["lastUpdated"] = now_iso
|
|
298
|
+
save_ledger(ledger)
|
|
299
|
+
print("Ledger written.")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sentry-digest.sh — critical (level:error/fatal) Sentry issue digest for the
|
|
3
|
+
# s4l Sentry project. Replaces the raw per-issue Sentry alert email; only
|
|
4
|
+
# emails when something is NEW or GROWING. Idempotent via
|
|
5
|
+
# scripts/state/sentry_digest_ledger.json. Wired by
|
|
6
|
+
# launchd/com.m13v.s4l-sentry-digest.plist (every 4 hours).
|
|
7
|
+
|
|
8
|
+
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
9
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
10
|
+
mkdir -p "$LOG_DIR"
|
|
11
|
+
LOG_FILE="$LOG_DIR/sentry-digest-$(date +%Y%m%d).log"
|
|
12
|
+
|
|
13
|
+
cd "$REPO_DIR" || exit 1
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
echo "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) sentry-digest run ==="
|
|
17
|
+
/usr/bin/python3 scripts/sentry_digest.py
|
|
18
|
+
echo
|
|
19
|
+
} >> "$LOG_FILE" 2>&1
|