@misterhuydo/sentinel 1.0.4 → 1.0.6

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,173 +1,188 @@
1
- """
2
- reporter.py Build and send HTML health-report emails.
3
-
4
- Scheduled every REPORT_INTERVAL_HOURS or triggered by SIGUSR1.
5
- """
6
-
7
- import logging
8
- import smtplib
9
- from datetime import datetime, timezone
10
- from email.mime.multipart import MIMEMultipart
11
- from email.mime.text import MIMEText
12
-
13
- from jinja2 import Template
14
-
15
- from .config_loader import SentinelConfig
16
- from .state_store import StateStore
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
- _HTML_TEMPLATE = Template("""\
21
- <!DOCTYPE html>
22
- <html>
23
- <head>
24
- <meta charset="utf-8">
25
- <style>
26
- body { font-family: Arial, sans-serif; font-size: 14px; color: #222; }
27
- h2 { color: #1a73e8; }
28
- h3 { color: #444; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
29
- table { border-collapse: collapse; width: 100%; margin-bottom: 16px; }
30
- th { background: #f1f3f4; text-align: left; padding: 6px 10px; }
31
- td { padding: 5px 10px; border-bottom: 1px solid #eee; }
32
- .ok { color: #2e7d32; }
33
- .fail { color: #c62828; }
34
- .warn { color: #e65100; }
35
- .pr-link { font-weight: bold; }
36
- .mono { font-family: monospace; font-size: 12px; }
37
- </style>
38
- </head>
39
- <body>
40
- <h2>🤖 Sentinel Health Report</h2>
41
- <p>Generated: <strong>{{ generated_at }}</strong></p>
42
-
43
- <h3>Summary (last {{ hours }}h)</h3>
44
- <table>
45
- <tr><th>Metric</th><th>Count</th></tr>
46
- <tr><td>Errors detected</td><td>{{ stats.errors }}</td></tr>
47
- <tr><td>Fixes applied</td><td class="ok">{{ stats.applied }}</td></tr>
48
- <tr><td>Fixes failed</td><td class="fail">{{ stats.failed }}</td></tr>
49
- <tr><td>Skipped</td><td class="warn">{{ stats.skipped }}</td></tr>
50
- </table>
51
-
52
- {% if open_prs %}
53
- <h3>⏳ Pending Review (AUTO_PUBLISH=false)</h3>
54
- <p>The following fixes are waiting for admin approval. Review and merge on GitHub:</p>
55
- <table>
56
- <tr><th>Fingerprint</th><th>Branch</th><th>PR</th><th>Age</th></tr>
57
- {% for pr in open_prs %}
58
- <tr>
59
- <td class="mono">{{ pr.fingerprint[:8] }}</td>
60
- <td class="mono">{{ pr.branch }}</td>
61
- <td><a class="pr-link" href="{{ pr.pr_url }}">{{ pr.pr_url }}</a></td>
62
- <td>{{ pr.age }}</td>
63
- </tr>
64
- {% endfor %}
65
- </table>
66
- {% endif %}
67
-
68
- {% if recent_fixes %}
69
- <h3>Recent Fix Activity</h3>
70
- <table>
71
- <tr><th>Time</th><th>Fingerprint</th><th>Status</th><th>Commit</th></tr>
72
- {% for fix in recent_fixes %}
73
- <tr>
74
- <td>{{ fix.timestamp }}</td>
75
- <td class="mono">{{ fix.fingerprint[:8] }}</td>
76
- <td class="{{ 'ok' if fix.status == 'applied' else 'fail' if fix.status == 'failed' else 'warn' }}">
77
- {{ fix.status }}
78
- </td>
79
- <td class="mono">{{ fix.commit_hash[:8] if fix.commit_hash else '-' }}</td>
80
- </tr>
81
- {% endfor %}
82
- </table>
83
- {% endif %}
84
-
85
- <hr>
86
- <small>Sentinel Autonomous DevOps Agent</small>
87
- </body>
88
- </html>
89
- """)
90
-
91
-
92
- def _age(ts_str: str) -> str:
93
- try:
94
- ts = datetime.fromisoformat(ts_str)
95
- if ts.tzinfo is None:
96
- ts = ts.replace(tzinfo=timezone.utc)
97
- delta = datetime.now(timezone.utc) - ts
98
- hours = int(delta.total_seconds() // 3600)
99
- if hours < 1:
100
- return f"{int(delta.total_seconds() // 60)}m"
101
- return f"{hours}h"
102
- except Exception:
103
- return "?"
104
-
105
-
106
- def build_and_send(cfg: SentinelConfig, store: StateStore):
107
- hours = cfg.report_interval_hours
108
- errors = store.get_recent_errors(hours)
109
- fixes = store.get_recent_fixes(hours)
110
- open_prs = store.get_open_prs()
111
-
112
- stats = {
113
- "errors": len(errors),
114
- "applied": sum(1 for f in fixes if f["status"] == "applied"),
115
- "failed": sum(1 for f in fixes if f["status"] == "failed"),
116
- "skipped": sum(1 for f in fixes if f["status"] == "skipped"),
117
- }
118
-
119
- # Annotate PRs with human-readable age
120
- for pr in open_prs:
121
- pr["age"] = _age(pr.get("timestamp", ""))
122
-
123
- html = _HTML_TEMPLATE.render(
124
- generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
125
- hours=hours,
126
- stats=stats,
127
- open_prs=open_prs,
128
- recent_fixes=fixes,
129
- )
130
-
131
- if not cfg.report_recipients:
132
- logger.warning("No REPORT_RECIPIENTS configured — skipping email")
133
- return
134
-
135
- _send_email(cfg, html, stats)
136
- store.record_report(
137
- recipient_count=len(cfg.report_recipients),
138
- summary=stats,
139
- )
140
-
141
-
142
- def _send_email(cfg: SentinelConfig, html: str, stats: dict):
143
- subject = (
144
- f"[Sentinel] Health Report "
145
- f"{stats['applied']} fixed, {stats['failed']} failed, "
146
- f"{stats['errors']} errors detected"
147
- )
148
-
149
- msg = MIMEMultipart("alternative")
150
- msg["Subject"] = subject
151
- msg["From"] = cfg.smtp_user
152
- msg["To"] = ", ".join(cfg.report_recipients)
153
- msg.attach(MIMEText(html, "html"))
154
-
155
- if cfg.smtp_host.lower() == "ses":
156
- _send_ses(cfg, msg)
157
- else:
158
- _send_smtp(cfg, msg)
159
- logger.info("Health report sent to %d recipient(s)", len(cfg.report_recipients))
160
-
161
-
162
- def _send_smtp(cfg: SentinelConfig, msg: MIMEMultipart):
163
- with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port) as smtp:
164
- smtp.ehlo()
165
- smtp.starttls()
166
- smtp.login(cfg.smtp_user, cfg.smtp_password)
167
- smtp.sendmail(cfg.smtp_user, cfg.report_recipients, msg.as_string())
168
-
169
-
170
- def _send_ses(cfg: SentinelConfig, msg: MIMEMultipart):
171
- # AWS SES via SMTP endpoint — same as SMTP with different host
172
- # Set SMTP_HOST=email-smtp.us-east-1.amazonaws.com and use SES SMTP credentials
173
- _send_smtp(cfg, msg)
1
+ """
2
+ reporter.py -- Email notifications for Sentinel.
3
+
4
+ Two modes:
5
+ 1. Per-fix notification -- sent immediately after every fix (always on).
6
+ 2. Health digest -- periodic summary, only if SEND_HEALTH=enabled.
7
+ """
8
+
9
+ import logging
10
+ import smtplib
11
+ from datetime import datetime, timezone
12
+ from email.mime.multipart import MIMEMultipart
13
+ from email.mime.text import MIMEText
14
+
15
+ from jinja2 import Template
16
+
17
+ from .config_loader import SentinelConfig
18
+ from .state_store import StateStore
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---- Templates ---------------------------------------------------------------
23
+
24
+ _FIX_TEMPLATE = Template('<!DOCTYPE html><html><head><meta charset="utf-8">\n<style>\n body{font-family:Arial,sans-serif;font-size:14px;color:#222}\n h2{color:#1a73e8;margin-bottom:4px}\n h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}\n table{border-collapse:collapse;width:100%;margin-bottom:16px}\n th{background:#f1f3f4;text-align:left;padding:6px 10px}\n td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}\n .label{font-weight:bold;width:160px}\n .ok{color:#2e7d32;font-weight:bold}\n .mono{font-family:monospace;font-size:12px}\n pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}\n .badge-a{background:#2e7d32;color:#fff;padding:2px 8px;border-radius:4px}\n .badge-p{background:#e65100;color:#fff;padding:2px 8px;border-radius:4px}\n</style></head><body>\n<h2>Sentinel Fix Report</h2>\n<p>\n <span class="{{ \'badge-a\' if auto_publish else \'badge-p\' }}">\n {{ \'PUSHED TO \' + branch|upper if auto_publish else \'PENDING REVIEW\' }}\n </span>\n &nbsp;<strong>{{ repo_name }}</strong> &middot; {{ generated_at }}\n</p>\n<h3>Error Detected</h3>\n<table>\n <tr><td class="label">Service</td><td class="mono">{{ source }}</td></tr>\n <tr><td class="label">Severity</td><td class="mono">{{ severity }}</td></tr>\n <tr><td class="label">Fingerprint</td><td class="mono">{{ fingerprint }}</td></tr>\n <tr><td class="label">First seen</td><td>{{ first_seen }}</td></tr>\n <tr><td class="label">Message</td><td class="mono">{{ message }}</td></tr>\n</table>\n{% if stack_trace %}<h3>Stack Trace</h3><pre>{{ stack_trace }}</pre>{% endif %}\n<h3>Fix Applied</h3>\n<table>\n <tr><td class="label">Repository</td><td class="mono">{{ repo_name }}</td></tr>\n <tr><td class="label">Commit</td><td class="mono">{{ commit_hash }}</td></tr>\n <tr><td class="label">Branch</td><td class="mono">{{ branch }}</td></tr>\n {% if pr_url %}\n <tr><td class="label">Pull Request</td>\n <td><a href="{{ pr_url }}">{{ pr_url }}</a> &mdash; review and merge to apply</td></tr>\n {% else %}\n <tr><td class="label">Status</td>\n <td class="ok">Pushed directly to {{ branch }}</td></tr>\n {% endif %}\n {% if files_changed %}\n <tr><td class="label">Files changed</td>\n <td class="mono">{{ files_changed | join(\'<br>\') }}</td></tr>\n {% endif %}\n</table>\n<hr><small>Sentinel &mdash; Autonomous DevOps Agent</small>\n</body></html>\n')
25
+
26
+ _HEALTH_TEMPLATE = Template('<!DOCTYPE html><html><head><meta charset="utf-8">\n<style>\n body{font-family:Arial,sans-serif;font-size:14px;color:#222}\n h2{color:#1a73e8}\n h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}\n table{border-collapse:collapse;width:100%;margin-bottom:16px}\n th{background:#f1f3f4;text-align:left;padding:6px 10px}\n td{padding:5px 10px;border-bottom:1px solid #eee}\n .ok{color:#2e7d32}.fail{color:#c62828}.warn{color:#e65100}\n .mono{font-family:monospace;font-size:12px}\n</style></head><body>\n<h2>Sentinel Health Digest</h2>\n<p>Generated: <strong>{{ generated_at }}</strong></p>\n<h3>Summary (last {{ hours }}h)</h3>\n<table>\n <tr><th>Metric</th><th>Count</th></tr>\n <tr><td>Errors detected</td><td>{{ stats.errors }}</td></tr>\n <tr><td>Fixes applied</td><td class="ok">{{ stats.applied }}</td></tr>\n <tr><td>Fixes failed</td><td class="fail">{{ stats.failed }}</td></tr>\n <tr><td>Skipped</td><td class="warn">{{ stats.skipped }}</td></tr>\n</table>\n{% if open_prs %}\n<h3>Pending Review (AUTO_PUBLISH=false)</h3>\n<table>\n <tr><th>Repo</th><th>Branch</th><th>PR</th><th>Age</th></tr>\n {% for pr in open_prs %}\n <tr>\n <td>{{ pr.repo_name }}</td>\n <td class="mono">{{ pr.branch }}</td>\n <td><a href="{{ pr.pr_url }}">{{ pr.pr_url }}</a></td>\n <td>{{ pr.age }}</td>\n </tr>\n {% endfor %}\n</table>\n{% endif %}\n<hr><small>Sentinel &mdash; Autonomous DevOps Agent</small>\n</body></html>\n')
27
+
28
+
29
+ # ---- Per-fix notification ----------------------------------------------------
30
+
31
+ def send_fix_notification(cfg: SentinelConfig, fix: dict):
32
+ """
33
+ Send an immediate email after a fix is applied or a PR is opened.
34
+
35
+ fix dict keys:
36
+ source, severity, fingerprint, first_seen, message, stack_trace,
37
+ repo_name, commit_hash, branch, pr_url, auto_publish, files_changed
38
+ """
39
+ if not cfg.mails:
40
+ logger.warning("No MAILS configured -- skipping fix notification")
41
+ return
42
+
43
+ auto_publish = fix.get("auto_publish", False)
44
+ source = fix.get("source", "unknown")
45
+ verb = "fix" if auto_publish else "PR"
46
+ subject = f"[Sentinel] {verb}({source}): {fix.get('message', '')[:80]}"
47
+
48
+ html = _FIX_TEMPLATE.render(
49
+ generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
50
+ auto_publish=auto_publish,
51
+ repo_name=fix.get("repo_name", "unknown"),
52
+ source=source,
53
+ severity=fix.get("severity", "ERROR"),
54
+ fingerprint=fix.get("fingerprint", ""),
55
+ first_seen=fix.get("first_seen", ""),
56
+ message=fix.get("message", ""),
57
+ stack_trace=fix.get("stack_trace", ""),
58
+ commit_hash=fix.get("commit_hash", ""),
59
+ branch=fix.get("branch", "unknown"),
60
+ pr_url=fix.get("pr_url") or "",
61
+ files_changed=fix.get("files_changed") or [],
62
+ )
63
+ _send_email(cfg, subject, html)
64
+ logger.info("Fix notification sent to %d recipient(s)", len(cfg.mails))
65
+
66
+
67
+ # ---- Health digest -----------------------------------------------------------
68
+
69
+ def build_and_send(cfg: SentinelConfig, store: StateStore):
70
+ """Send periodic health digest. Only called if SEND_HEALTH=enabled."""
71
+ if not cfg.mails:
72
+ logger.warning("No MAILS configured -- skipping health digest")
73
+ return
74
+
75
+ hours = cfg.report_interval_hours
76
+ errors = store.get_recent_errors(hours)
77
+ fixes = store.get_recent_fixes(hours)
78
+ open_prs = store.get_open_prs()
79
+
80
+ stats = {
81
+ "errors": len(errors),
82
+ "applied": sum(1 for f in fixes if f["status"] == "applied"),
83
+ "failed": sum(1 for f in fixes if f["status"] == "failed"),
84
+ "skipped": sum(1 for f in fixes if f["status"] == "skipped"),
85
+ }
86
+ for pr in open_prs:
87
+ pr["age"] = _age(pr.get("timestamp", ""))
88
+
89
+ html = _HEALTH_TEMPLATE.render(
90
+ generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
91
+ hours=hours, stats=stats, open_prs=open_prs,
92
+ )
93
+ subject = (
94
+ f"[Sentinel] Health Digest -- "
95
+ f"{stats['applied']} fixed, {stats['failed']} failed, "
96
+ f"{stats['errors']} detected"
97
+ )
98
+ _send_email(cfg, subject, html)
99
+ store.record_report(recipient_count=len(cfg.mails), summary=stats)
100
+ logger.info("Health digest sent to %d recipient(s)", len(cfg.mails))
101
+
102
+
103
+ # ---- Shared helpers ----------------------------------------------------------
104
+
105
+ def _send_email(cfg: SentinelConfig, subject: str, html: str):
106
+ msg = MIMEMultipart("alternative")
107
+ msg["Subject"] = subject
108
+ msg["From"] = cfg.smtp_user
109
+ msg["To"] = ", ".join(cfg.mails)
110
+ msg.attach(MIMEText(html, "html"))
111
+ if cfg.smtp_host.lower() == "ses":
112
+ _send_ses(cfg, msg)
113
+ else:
114
+ _send_smtp(cfg, msg)
115
+
116
+
117
+ def _send_smtp(cfg: SentinelConfig, msg: MIMEMultipart):
118
+ with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port) as smtp:
119
+ smtp.ehlo()
120
+ smtp.starttls()
121
+ smtp.login(cfg.smtp_user, cfg.smtp_password)
122
+ smtp.sendmail(cfg.smtp_user, cfg.mails, msg.as_string())
123
+
124
+
125
+ def _send_ses(cfg: SentinelConfig, msg: MIMEMultipart):
126
+ _send_smtp(cfg, msg)
127
+
128
+
129
+ def _age(ts_str: str) -> str:
130
+ try:
131
+ ts = datetime.fromisoformat(ts_str)
132
+ if ts.tzinfo is None:
133
+ ts = ts.replace(tzinfo=timezone.utc)
134
+ delta = datetime.now(timezone.utc) - ts
135
+ hours = int(delta.total_seconds() // 3600)
136
+ return f"{int(delta.total_seconds() // 60)}m" if hours < 1 else f"{hours}h"
137
+ except Exception:
138
+ return "?"
139
+
140
+ def send_failure_notification(cfg: SentinelConfig, details: dict):
141
+ """
142
+ Notify admins when Claude Code cannot fix a problem (from logs or issues/).
143
+
144
+ details dict keys: source, message, repo_name, reason, body
145
+ """
146
+ if not cfg.mails:
147
+ return
148
+
149
+ source = details.get('source', 'unknown')
150
+ repo_name = details.get('repo_name', 'unknown')
151
+ reason = details.get('reason', 'unknown')
152
+ message = details.get('message', '')
153
+ body = details.get('body', '')[:1000]
154
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
155
+
156
+ subject = f'[Sentinel] UNRESOLVED ({source}): {message[:80]}'
157
+
158
+ ctx_html = f'<h3>Context</h3><pre>{body}</pre>' if body else ''
159
+ html = (
160
+ '<!DOCTYPE html><html><head><meta charset="utf-8">'
161
+ '<style>'
162
+ 'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
163
+ 'h2{color:#c62828}'
164
+ 'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
165
+ 'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
166
+ 'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
167
+ 'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
168
+ '.label{font-weight:bold;width:160px}'
169
+ '.mono{font-family:monospace;font-size:12px}'
170
+ 'pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}'
171
+ '</style></head><body>'
172
+ '<h2>&#x26A0; Sentinel could not fix this issue</h2>'
173
+ f'<p><strong>{repo_name}</strong> &middot; {ts}</p>'
174
+ '<h3>Details</h3>'
175
+ '<table>'
176
+ f'<tr><td class="label">Source</td><td class="mono">{source}</td></tr>'
177
+ f'<tr><td class="label">Repository</td><td class="mono">{repo_name}</td></tr>'
178
+ f'<tr><td class="label">Message</td><td class="mono">{message}</td></tr>'
179
+ f'<tr><td class="label">Reason</td><td>{reason}</td></tr>'
180
+ '</table>'
181
+ + ctx_html +
182
+ '<hr><small>Sentinel &mdash; Autonomous DevOps Agent</small>'
183
+ '</body></html>'
184
+ )
185
+
186
+ _send_email(cfg, subject, html)
187
+ logger.info('Failure notification sent for %s', source)
188
+
@@ -1,31 +1,32 @@
1
- # Sentinel master config
2
-
3
- # Schedule
4
- POLL_INTERVAL_SECONDS=120
5
-
6
- # Email reporting
7
- SMTP_HOST=smtp.gmail.com
8
- SMTP_PORT=587
9
- SMTP_USER=sentinel@yourdomain.com
10
- SMTP_PASSWORD=<app-password>
11
- REPORT_RECIPIENTS=huy@yourdomain.com
12
- REPORT_INTERVAL_HOURS=1
13
-
14
- # State DB
15
- STATE_DB=./sentinel.db
16
-
17
- # Workspace
18
- WORKSPACE_DIR=./workspace
19
-
20
- # Claude Code binary path
21
- CLAUDE_CODE_BIN=claude
22
-
23
- # GitHub token (required for opening PRs when AUTO_PUBLISH=false)
24
- GITHUB_TOKEN=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
25
-
26
- # Fix confidence threshold (0.0 - 1.0); fixes below this are skipped
27
- FIX_CONFIDENCE_THRESHOLD=0.7
28
-
29
- # Rolling log retention window — fetched logs older than this are pruned
30
- # Claude Code reads the full window for context when generating fixes
31
- LOG_RETENTION_HOURS=48
1
+ # Sentinel master config
2
+
3
+ # Schedule
4
+ POLL_INTERVAL_SECONDS=120
5
+
6
+ # Email reporting
7
+ SMTP_HOST=smtp.gmail.com
8
+ SMTP_PORT=587
9
+ SMTP_USER=sentinel@yourdomain.com
10
+ SMTP_PASSWORD=<app-password>
11
+ MAILS=huy@yourdomain.com
12
+ SEND_HEALTH=disabled
13
+ REPORT_INTERVAL_HOURS=1
14
+
15
+ # State DB
16
+ STATE_DB=./sentinel.db
17
+
18
+ # Workspace
19
+ WORKSPACE_DIR=./workspace
20
+
21
+ # Claude Code binary path
22
+ CLAUDE_CODE_BIN=claude
23
+
24
+ # GitHub token (required for opening PRs when AUTO_PUBLISH=false)
25
+ GITHUB_TOKEN=<github-pat>
26
+
27
+ # Fix confidence threshold (0.0 - 1.0); fixes below this are skipped
28
+ FIX_CONFIDENCE_THRESHOLD=0.7
29
+
30
+ # Rolling log retention window fetched logs older than this are pruned
31
+ # Claude Code reads the full window for context when generating fixes
32
+ LOG_RETENTION_HOURS=48