@misterhuydo/sentinel 1.5.4 → 1.5.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.
Files changed (38) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +13 -1
  3. package/.cairn/session.json +2 -2
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/5f5141_main.py +1067 -0
  6. package/.cairn/views/62a614_bundle.js +4 -1
  7. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  8. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  9. package/lib/.cairn/minify-map.json +6 -0
  10. package/lib/.cairn/views/2a85cc_init.js +380 -0
  11. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  12. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  13. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  14. package/lib/init.js +54 -0
  15. package/lib/maven.js +212 -0
  16. package/lib/slack-setup.js +5 -0
  17. package/package.json +1 -1
  18. package/python/requirements.txt +1 -0
  19. package/python/sentinel/.cairn/.cairn-project +0 -0
  20. package/python/sentinel/.cairn/.hint-lock +1 -0
  21. package/python/sentinel/.cairn/session.json +9 -0
  22. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  23. package/python/sentinel/config_loader.py +29 -10
  24. package/python/sentinel/dependency_manager.py +9 -2
  25. package/python/sentinel/git_manager.py +23 -0
  26. package/python/sentinel/issue_watcher.py +7 -1
  27. package/python/sentinel/main.py +353 -8
  28. package/python/sentinel/notify.py +44 -12
  29. package/python/sentinel/repo_task_engine.py +49 -7
  30. package/python/sentinel/sentinel_boss.py +117 -3
  31. package/python/sentinel/slack_bot.py +15 -2
  32. package/python/sentinel/state_store.py +0 -1
  33. package/python/tests/__init__.py +0 -0
  34. package/python/tests/test_config_loader.py +138 -0
  35. package/python/tests/test_log_parser.py +62 -0
  36. package/python/tests/test_repo_router.py +73 -0
  37. package/python/tests/test_smoke.py +96 -0
  38. package/python/tests/test_state_store.py +128 -0
@@ -193,6 +193,35 @@ def slack_dm(bot_token: str, user_id: str, text: str) -> None:
193
193
  logger.warning("slack_dm: failed to DM %s: %s", user_id, exc)
194
194
 
195
195
 
196
+ def notify_nexus_auth_failure(cfg, repo_name: str, context: str, mvn_output: str) -> None:
197
+ """
198
+ DM the first admin user when a Maven build fails due to missing/invalid Nexus credentials.
199
+ Called from git_manager, dependency_manager, and sentinel_boss (chain_release) when
200
+ MavenAuthError is raised.
201
+ """
202
+ if not cfg.slack_admin_users or not cfg.slack_bot_token:
203
+ logger.warning("Cannot DM admin about Nexus auth failure — no admin users or bot token configured")
204
+ return
205
+ snippet = mvn_output[-400:].strip() if mvn_output else ""
206
+ lines = [
207
+ f":lock: *Maven authentication failed* during `{context}` on `{repo_name}`",
208
+ "",
209
+ "Nexus credentials in `~/.m2/settings.xml` are missing or invalid.",
210
+ "",
211
+ "Fix it by sending me one of:",
212
+ "",
213
+ "*Option 1 — paste your settings.xml*",
214
+ "`nexus settings`",
215
+ "then the full XML content on the next line(s).",
216
+ "",
217
+ "*Option 2 — credentials per host*",
218
+ "`nexus creds <host> <username> <password>`",
219
+ ]
220
+ if snippet:
221
+ lines += ["", f"```\n{snippet}\n```"]
222
+ slack_dm(cfg.slack_bot_token, cfg.slack_admin_users[0], "\n".join(lines))
223
+
224
+
196
225
  def notify_fix_blocked(
197
226
  cfg,
198
227
  source: str,
@@ -200,13 +229,13 @@ def notify_fix_blocked(
200
229
  reason: str,
201
230
  repo_name: str = "",
202
231
  submitter_user_id: str = "",
232
+ origin_channel: str = "",
203
233
  ) -> None:
204
234
  """
205
235
  Notify that a fix needs human intervention.
206
236
 
207
- - If submitter_user_id is known: DM that person directly.
208
- - Otherwise: @channel in the configured Slack channel.
209
- - Always: email admins via reporter.send_failure_notification.
237
+ Posts to origin_channel if set (where the issue was raised), otherwise
238
+ falls back to cfg.slack_channel. Always emails admins.
210
239
  """
211
240
  short_reason = (reason or "Claude could not determine a safe fix.")[:600]
212
241
  repo_line = f"\n*Repo:* {repo_name}" if repo_name else ""
@@ -218,11 +247,11 @@ def notify_fix_blocked(
218
247
  f"*Reason:*\n{short_reason}"
219
248
  )
220
249
 
221
- # Post to channel — mention submitter if known, otherwise broadcast
250
+ target_channel = origin_channel or cfg.slack_channel
222
251
  if submitter_user_id:
223
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
252
+ slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
224
253
  else:
225
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
254
+ slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
226
255
 
227
256
  # Always email admins
228
257
  try:
@@ -260,6 +289,7 @@ def notify_missing_tool(
260
289
  repo_name: str,
261
290
  source: str,
262
291
  submitter_user_id: str = "",
292
+ origin_channel: str = "",
263
293
  ) -> None:
264
294
  """
265
295
  Notify admins that a build tool is missing on this server.
@@ -282,10 +312,11 @@ def notify_missing_tool(
282
312
  f"*How to fix:*\n{steps}\n\n"
283
313
  f"Then tell me: `retry {repo_name or source}`"
284
314
  )
315
+ target_channel = origin_channel or cfg.slack_channel
285
316
  if submitter_user_id:
286
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
317
+ slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
287
318
  else:
288
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
319
+ slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
289
320
 
290
321
 
291
322
  def notify_fix_applied(
@@ -296,10 +327,11 @@ def notify_fix_applied(
296
327
  branch: str,
297
328
  pr_url: str,
298
329
  submitter_user_id: str = "",
330
+ origin_channel: str = "",
299
331
  ) -> None:
300
332
  """
301
- DM the submitter (if known) that their issue was fixed.
302
- Falls back to posting in the Slack channel if no submitter.
333
+ Notify that a fix was applied. Posts to origin_channel if set (where
334
+ the issue was raised), otherwise falls back to cfg.slack_channel.
303
335
  """
304
336
  repo_line = f" in *{repo_name}*" if repo_name else ""
305
337
  if pr_url:
@@ -315,9 +347,9 @@ def notify_fix_applied(
315
347
  + (f"{action_line}\n" if action_line else "")
316
348
  ).rstrip()
317
349
 
318
- # Post to channel — mention submitter if known
350
+ target_channel = origin_channel or cfg.slack_channel
319
351
  channel_text = f"<@{submitter_user_id}> {slack_text}" if submitter_user_id else slack_text
320
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, channel_text)
352
+ slack_alert(cfg.slack_bot_token, target_channel, channel_text)
321
353
 
322
354
 
323
355
  def notify_cascade_started(
@@ -12,6 +12,7 @@ File format:
12
12
  TYPE: feature|fix|refactor|chore
13
13
  SUBMITTED_BY: <@UXXX> (UXXX)
14
14
  SUBMITTED_AT: 2026-03-27T10:00:00+00:00
15
+ RUN_AT: 2026-03-28T02:00:00+00:00 # optional — task is held until this UTC time
15
16
  NOTIFY: U1234567,U7654321 # optional
16
17
 
17
18
  Full task description — what to implement, fix, or refactor.
@@ -33,7 +34,7 @@ from .git_manager import _git_env
33
34
 
34
35
  logger = logging.getLogger(__name__)
35
36
 
36
- _META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "NOTIFY:")
37
+ _META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "RUN_AT:", "NOTIFY:")
37
38
  _TASK_TIMEOUT = 900 # 15 minutes
38
39
 
39
40
 
@@ -48,6 +49,7 @@ class RepoTask:
48
49
  notify_user_ids: list = field(default_factory=list)
49
50
  fingerprint: str = ""
50
51
  timestamp: str = ""
52
+ run_at: datetime | None = None # UTC; task is held until this time if set
51
53
 
52
54
  def __post_init__(self):
53
55
  if not self.fingerprint:
@@ -289,12 +291,20 @@ def run_repo_task(
289
291
  if repo.cicd_type:
290
292
  try:
291
293
  from .cicd_trigger import trigger as cicd_trigger
292
- cicd_trigger(repo, None, task.fingerprint)
293
- logger.info("Repo task: CI/CD triggered for %s (%s)", repo.repo_name, repo.cicd_type)
294
- if on_progress:
295
- on_progress(f":rocket: Release triggered via `{repo.cicd_type}`")
294
+ ok = cicd_trigger(repo, None, task.fingerprint)
295
+ if ok:
296
+ logger.info("Repo task: CI/CD triggered for %s (%s)", repo.repo_name, repo.cicd_type)
297
+ if on_progress:
298
+ on_progress(f":rocket: Release triggered via `{repo.cicd_type}`")
299
+ return "done", f"__cicd__{repo.cicd_type}"
300
+ else:
301
+ logger.warning("Repo task: CI/CD trigger failed for %s", repo.repo_name)
302
+ if on_progress:
303
+ on_progress(f":warning: CI/CD trigger failed for `{repo.cicd_type}` — check logs")
296
304
  except Exception as exc:
297
305
  logger.warning("Repo task: CI/CD trigger failed for %s: %s", repo.repo_name, exc)
306
+ if on_progress:
307
+ on_progress(f":warning: CI/CD trigger error — {exc}")
298
308
  return "done", None
299
309
  else:
300
310
  branch = f"sentinel/task-{task.fingerprint[:8]}"
@@ -323,8 +333,13 @@ def drop_repo_task(
323
333
  description: str,
324
334
  submitter_user_id: str = "",
325
335
  notify_user_ids: list | None = None,
336
+ run_at: datetime | None = None,
326
337
  ) -> Path:
327
- """Drop a repo task file into <project_dir>/repo-tasks/."""
338
+ """Drop a repo task file into <project_dir>/repo-tasks/.
339
+
340
+ If run_at (UTC-aware datetime) is given the task will be held by the poll
341
+ loop until that time has passed before executing.
342
+ """
328
343
  tasks_dir = project_dir / "repo-tasks"
329
344
  tasks_dir.mkdir(exist_ok=True)
330
345
  import uuid as _uuid
@@ -338,11 +353,16 @@ def drop_repo_task(
338
353
  if submitter_user_id else "SUBMITTED_BY: system"),
339
354
  f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
340
355
  ]
356
+ if run_at is not None:
357
+ lines.append(f"RUN_AT: {run_at.astimezone(timezone.utc).isoformat()}")
341
358
  if notify_user_ids:
342
359
  lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
343
360
  lines += ["", description]
344
361
  fpath.write_text("\n".join(lines), encoding="utf-8")
345
- logger.info("Dropped repo task: %s", fname)
362
+ if run_at:
363
+ logger.info("Dropped repo task (scheduled): %s — run_at=%s", fname, run_at.isoformat())
364
+ else:
365
+ logger.info("Dropped repo task: %s", fname)
346
366
  return fpath
347
367
 
348
368
 
@@ -367,6 +387,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
367
387
  task_type = "feature"
368
388
  submitter_user_id = ""
369
389
  notify_user_ids: list = []
390
+ run_at: datetime | None = None
370
391
  body_start = 0
371
392
 
372
393
  for i, line in enumerate(lines):
@@ -384,6 +405,13 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
384
405
  if m:
385
406
  submitter_user_id = m.group(1)
386
407
  body_start = i + 1
408
+ elif upper.startswith("RUN_AT:"):
409
+ raw_ts = stripped[7:].strip()
410
+ try:
411
+ run_at = datetime.fromisoformat(raw_ts).astimezone(timezone.utc)
412
+ except ValueError:
413
+ logger.warning("Repo task %s: invalid RUN_AT value '%s' — ignoring", f.name, raw_ts)
414
+ body_start = i + 1
387
415
  elif upper.startswith("NOTIFY:"):
388
416
  notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
389
417
  body_start = i + 1
@@ -399,6 +427,19 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
399
427
  logger.warning("Repo task %s has no REPO: header — skipping", f.name)
400
428
  continue
401
429
 
430
+ # Hold scheduled tasks until their run_at time has passed
431
+ if run_at is not None:
432
+ now_utc = datetime.now(timezone.utc)
433
+ if now_utc < run_at:
434
+ remaining = run_at - now_utc
435
+ hours, rem = divmod(int(remaining.total_seconds()), 3600)
436
+ minutes = rem // 60
437
+ logger.debug(
438
+ "Repo task %s scheduled for %s — holding (%dh %dm remaining)",
439
+ f.name, run_at.isoformat(), hours, minutes,
440
+ )
441
+ continue
442
+
402
443
  tasks.append(RepoTask(
403
444
  task_file=f,
404
445
  repo_name=repo_name,
@@ -407,6 +448,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
407
448
  message=message,
408
449
  submitter_user_id=submitter_user_id,
409
450
  notify_user_ids=notify_user_ids,
451
+ run_at=run_at,
410
452
  ))
411
453
  logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
412
454
 
@@ -814,12 +814,13 @@ _TOOLS = [
814
814
  {
815
815
  "name": "repo_task",
816
816
  "description": (
817
- "ADMIN ONLY. Submit a feature, fix, or refactor task for a managed repo. "
817
+ "ADMIN ONLY. Submit a feature, fix, refactor, or scheduled deploy task for a managed repo. "
818
818
  "Claude Code will run against the repo's local clone, implement the change, "
819
819
  "commit, and push (or open a PR if AUTO_PUBLISH=false). "
820
820
  "ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
821
821
  "Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
822
- "'refactor OrderService', any human-requested change to a managed repo."
822
+ "'refactor OrderService', 'deploy UIB at 3 AM tomorrow', "
823
+ "any human-requested change to a managed repo — immediate or scheduled."
823
824
  ),
824
825
  "input_schema": {
825
826
  "type": "object",
@@ -843,6 +844,16 @@ _TOOLS = [
843
844
  "items": {"type": "string"},
844
845
  "description": "Extra Slack user IDs to ping on completion (besides the submitter).",
845
846
  },
847
+ "run_at": {
848
+ "type": "string",
849
+ "description": (
850
+ "Optional. Schedule the task for a future time. "
851
+ "Accepts natural language (e.g. '3 AM tomorrow Norwegian time', "
852
+ "'kl 03:00 norsk tid', 'at 15:30 CET', 'in 2 hours') "
853
+ "or an ISO 8601 datetime string. "
854
+ "If omitted the task runs immediately on the next poll cycle."
855
+ ),
856
+ },
846
857
  },
847
858
  "required": ["repo_name", "description"],
848
859
  },
@@ -1628,6 +1639,79 @@ _TOOLS = [
1628
1639
  ]
1629
1640
 
1630
1641
 
1642
+ # ── Scheduling helpers ────────────────────────────────────────────────────────
1643
+
1644
+ def _parse_run_at(raw: str) -> tuple:
1645
+ """Parse a natural-language or ISO 8601 time expression into a UTC-aware datetime.
1646
+
1647
+ Returns (datetime, None) on success, (None, error_str) on failure.
1648
+
1649
+ Understands:
1650
+ - ISO 8601 strings with or without timezone
1651
+ - "at HH:MM [TZ]" / "kl HH:MM [norsk tid|CET|CEST|Oslo]"
1652
+ - "in N hours/minutes"
1653
+ - "tomorrow" modifier before or after HH:MM
1654
+ - Norwegian timezone keywords → Europe/Oslo
1655
+ """
1656
+ import re as _re
1657
+ from datetime import datetime as _dt, timezone as _tz, timedelta as _td
1658
+
1659
+ _UTC = _tz.utc
1660
+
1661
+ # Build Oslo timezone — prefer zoneinfo (accurate DST), fall back to fixed offset
1662
+ try:
1663
+ import zoneinfo as _zi
1664
+ _OSLO = _zi.ZoneInfo("Europe/Oslo")
1665
+ except Exception:
1666
+ # tzdata not installed or unavailable; use fixed UTC+1 (CET, conservative)
1667
+ _OSLO = _tz(_td(hours=1)) # type: ignore[assignment]
1668
+
1669
+ raw_l = raw.lower().strip()
1670
+
1671
+ # ── ISO 8601 ───────────────────────────────────────────────────────────────
1672
+ try:
1673
+ parsed = _dt.fromisoformat(raw)
1674
+ if parsed.tzinfo is None:
1675
+ parsed = parsed.replace(tzinfo=_OSLO)
1676
+ return parsed.astimezone(_UTC), None
1677
+ except ValueError:
1678
+ pass
1679
+
1680
+ # ── "in N hours/minutes" ──────────────────────────────────────────────────
1681
+ m = _re.search(r'in\s+(\d+)\s*(hour|hr|minute|min)', raw_l)
1682
+ if m:
1683
+ n = int(m.group(1))
1684
+ unit = m.group(2)
1685
+ delta = _td(hours=n) if unit.startswith("h") else _td(minutes=n)
1686
+ return _dt.now(_UTC) + delta, None
1687
+
1688
+ # ── "at HH:MM" / "kl HH:MM" with optional TZ and "tomorrow" ──────────────
1689
+ m = _re.search(r'(?:kl\.?\s*|at\s+)?(\d{1,2})[:\.](\d{2})', raw_l)
1690
+ if m:
1691
+ hour, minute = int(m.group(1)), int(m.group(2))
1692
+
1693
+ tz_hint = raw_l
1694
+ if any(kw in tz_hint for kw in ("norsk", "oslo", "norway", "norwegian", "cet", "cest")):
1695
+ tz = _OSLO
1696
+ elif "utc" in tz_hint:
1697
+ tz = _UTC
1698
+ else:
1699
+ tz = _OSLO # default for this project
1700
+
1701
+ today = _dt.now(tz).date()
1702
+ tomorrow = today + _td(days=1)
1703
+ base_date = tomorrow if "tomorrow" in raw_l or "i morgen" in raw_l else today
1704
+
1705
+ candidate = _dt(base_date.year, base_date.month, base_date.day, hour, minute, tzinfo=tz)
1706
+ # If the time is already past today, roll to tomorrow automatically
1707
+ if candidate <= _dt.now(tz) and "tomorrow" not in raw_l and "i morgen" not in raw_l:
1708
+ candidate += _td(days=1)
1709
+
1710
+ return candidate.astimezone(_UTC), None
1711
+
1712
+ return None, f"Unrecognised time expression: '{raw}'"
1713
+
1714
+
1631
1715
  # ── Workspace helpers ─────────────────────────────────────────────────────────
1632
1716
 
1633
1717
  def _workspace_dir() -> Path:
@@ -2181,6 +2265,15 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2181
2265
  if isinstance(notify_ids, list):
2182
2266
  notify_ids = [u for u in notify_ids if u and u != user_id]
2183
2267
 
2268
+ # Parse optional scheduled time
2269
+ run_at_raw = (inputs.get("run_at") or "").strip()
2270
+ run_at_dt = None
2271
+ run_at_parse_error = None
2272
+ if run_at_raw:
2273
+ run_at_dt, run_at_parse_error = _parse_run_at(run_at_raw)
2274
+ if run_at_parse_error:
2275
+ return json.dumps({"error": f"Could not parse run_at: {run_at_parse_error}"})
2276
+
2184
2277
  _project_dirs = _find_project_dirs()
2185
2278
  if not _project_dirs:
2186
2279
  return json.dumps({"error": "No project directory found."})
@@ -2193,8 +2286,29 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2193
2286
  description=description,
2194
2287
  submitter_user_id=user_id,
2195
2288
  notify_user_ids=notify_ids,
2289
+ run_at=run_at_dt,
2290
+ )
2291
+ logger.info(
2292
+ "Boss repo_task: dropped %s for user %s (repo=%s, run_at=%s)",
2293
+ task_file.name, user_id, repo_name,
2294
+ run_at_dt.isoformat() if run_at_dt else "immediate",
2196
2295
  )
2197
- logger.info("Boss repo_task: dropped %s for user %s (repo=%s)", task_file.name, user_id, repo_name)
2296
+ if run_at_dt:
2297
+ import zoneinfo as _zi
2298
+ oslo = _zi.ZoneInfo("Europe/Oslo")
2299
+ local_str = run_at_dt.astimezone(oslo).strftime("%Y-%m-%d %H:%M %Z")
2300
+ return json.dumps({
2301
+ "status": "scheduled",
2302
+ "repo": repo_name,
2303
+ "task_type": task_type,
2304
+ "file": task_file.name,
2305
+ "run_at_utc": run_at_dt.isoformat(),
2306
+ "run_at_local": local_str,
2307
+ "note": (
2308
+ f"Task scheduled for `{repo_name}` at {local_str} — "
2309
+ "Sentinel will pick it up automatically when the time arrives."
2310
+ ),
2311
+ })
2198
2312
  return json.dumps({
2199
2313
  "status": "queued",
2200
2314
  "repo": repo_name,
@@ -60,6 +60,10 @@ async def _get_or_create_session(user_id: str, user_name: str, channel: str) ->
60
60
  async with _sessions_lock:
61
61
  if user_id not in _sessions:
62
62
  _sessions[user_id] = _Session(user_id, user_name, channel)
63
+ else:
64
+ # Always update channel so replies go to wherever the current message came from,
65
+ # not the channel where the session was first created (e.g. a prior DM).
66
+ _sessions[user_id].channel = channel
63
67
  return _sessions[user_id]
64
68
 
65
69
 
@@ -353,12 +357,21 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
353
357
  if not text:
354
358
  text = "hello"
355
359
 
356
- # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
357
- # Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS
360
+ # Allowlist check — if SLACK_ALLOWED_USERS is configured, only those users + admins may interact.
361
+ # Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS.
358
362
  allowed = cfg_loader.sentinel.slack_allowed_users
359
363
  admin_users = cfg_loader.sentinel.slack_admin_users or []
360
364
  if allowed and user_id not in allowed and user_id not in admin_users:
361
365
  logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
366
+ # For DMs: send a polite rejection so the user knows they're not authorized
367
+ if event.get("channel_type") == "im":
368
+ try:
369
+ await client.chat_postMessage(
370
+ channel=channel,
371
+ text="Sorry, you're not authorized to interact with Sentinel. Contact an admin to get access.",
372
+ )
373
+ except Exception:
374
+ pass
362
375
  return
363
376
 
364
377
  user_name, user_tz = await _resolve_name(client, user_id)
@@ -540,7 +540,6 @@ class StateStore:
540
540
  """Remove a fix record so Sentinel will retry the error on the next poll."""
541
541
  with self._conn() as conn:
542
542
  cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
543
- conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
544
543
  return cur.rowcount > 0
545
544
 
546
545
  def get_all_errors(self, hours: int = 0) -> list[dict]:
File without changes
@@ -0,0 +1,138 @@
1
+ """Tests for config_loader."""
2
+
3
+ import textwrap
4
+
5
+ import pytest
6
+
7
+ from sentinel.config_loader import ConfigLoader
8
+
9
+
10
+ @pytest.fixture
11
+ def config_dir(tmp_path):
12
+ (tmp_path / "log-configs").mkdir()
13
+ (tmp_path / "repo-configs").mkdir()
14
+
15
+ (tmp_path / "sentinel.properties").write_text(textwrap.dedent("""\
16
+ POLL_INTERVAL_SECONDS=60
17
+ SMTP_HOST=smtp.example.com
18
+ SMTP_PORT=465
19
+ SMTP_USER=bot@example.com
20
+ SMTP_PASSWORD=secret
21
+ MAILS=admin@example.com, ops@example.com
22
+ REPORT_INTERVAL_HOURS=4
23
+ STATE_DB=./test.db
24
+ WORKSPACE_DIR=./workspace
25
+ CLAUDE_CODE_BIN=claude
26
+ GITHUB_TOKEN=ghp_test
27
+ FIX_CONFIDENCE_THRESHOLD=0.8
28
+ """))
29
+
30
+ (tmp_path / "log-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
31
+ SOURCE_TYPE=ssh
32
+ KEY=/path/to/key.pem
33
+ HOSTS=host1, host2
34
+ LOGS=logs/app.log, logs/alarm.log
35
+ REMOTE_SERVICE_USER=MyService
36
+ GREP_FILTER=ERROR|WARN
37
+ TAIL=200
38
+ """))
39
+
40
+ (tmp_path / "log-configs" / "my-worker.properties").write_text(textwrap.dedent("""\
41
+ SOURCE_TYPE=cloudflare
42
+ CF_URL=https://worker.example.com/logs
43
+ CF_TOKEN=cf_token_abc
44
+ """))
45
+
46
+ (tmp_path / "repo-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
47
+ REPO_URL=git@github.com:org/elprint-salescore.git
48
+ LOCAL_PATH=/repos/elprint-salescore
49
+ BRANCH=main
50
+ AUTO_PUBLISH=false
51
+ CICD_TYPE=jenkins
52
+ CICD_JOB_URL=https://jenkins.quadim.ai/job/elprint-salescore
53
+ CICD_TOKEN=tok123
54
+ """))
55
+
56
+ # Library — no log-config counterpart
57
+ (tmp_path / "repo-configs" / "elprint-commons.properties").write_text(textwrap.dedent("""\
58
+ REPO_URL=git@github.com:org/elprint-commons.git
59
+ LOCAL_PATH=/repos/elprint-commons
60
+ BRANCH=main
61
+ AUTO_PUBLISH=false
62
+ CICD_TYPE=
63
+ CICD_JOB_URL=
64
+ CICD_TOKEN=
65
+ """))
66
+
67
+ return tmp_path
68
+
69
+
70
+ def test_sentinel_config(config_dir):
71
+ loader = ConfigLoader(str(config_dir))
72
+ cfg = loader.sentinel
73
+ assert cfg.poll_interval_seconds == 60
74
+ assert cfg.smtp_host == "smtp.example.com"
75
+ assert cfg.mails == ["admin@example.com", "ops@example.com"]
76
+ assert cfg.fix_confidence_threshold == 0.8
77
+ assert cfg.github_token == "ghp_test"
78
+
79
+
80
+ def test_log_sources(config_dir):
81
+ loader = ConfigLoader(str(config_dir))
82
+ assert "elprint-salescore" in loader.log_sources
83
+ assert "my-worker" in loader.log_sources
84
+
85
+ ssh = loader.log_sources["elprint-salescore"]
86
+ assert ssh.source_type == "ssh"
87
+ assert ssh.hosts == ["host1", "host2"]
88
+ assert ssh.tail == 200
89
+ assert ssh.name == "elprint-salescore"
90
+
91
+ cf = loader.log_sources["my-worker"]
92
+ assert cf.source_type == "cloudflare"
93
+ assert cf.cf_url == "https://worker.example.com/logs"
94
+
95
+
96
+ def test_repos(config_dir):
97
+ loader = ConfigLoader(str(config_dir))
98
+ assert "elprint-salescore" in loader.repos
99
+ assert "elprint-commons" in loader.repos
100
+
101
+ r = loader.repos["elprint-salescore"]
102
+ assert r.repo_name == "elprint-salescore" # derived from stem
103
+ assert r.auto_publish is False
104
+ assert r.cicd_job_url == "https://jenkins.quadim.ai/job/elprint-salescore"
105
+ assert r.cicd_token == "tok123"
106
+
107
+ lib = loader.repos["elprint-commons"]
108
+ assert lib.cicd_job_url == "" # library, no CI/CD
109
+
110
+
111
+ def test_stem_linking(config_dir):
112
+ """Log-config and repo-config with same stem are linkable by key lookup."""
113
+ loader = ConfigLoader(str(config_dir))
114
+ # elprint-salescore exists in both — they link by stem
115
+ assert "elprint-salescore" in loader.log_sources
116
+ assert "elprint-salescore" in loader.repos
117
+ # elprint-commons has no log-config — that's fine
118
+ assert "elprint-commons" not in loader.log_sources
119
+ assert "elprint-commons" in loader.repos
120
+
121
+
122
+ def test_inline_comments_stripped(config_dir):
123
+ (config_dir / "repo-configs" / "test-svc.properties").write_text(
124
+ "REPO_URL=git@github.com:org/test.git # comment\nLOCAL_PATH=/repos/test\n"
125
+ )
126
+ loader = ConfigLoader(str(config_dir))
127
+ assert loader.repos["test-svc"].repo_url == "git@github.com:org/test.git"
128
+
129
+
130
+ def test_no_redundant_fields(config_dir):
131
+ """Confirm removed fields are not present on config objects."""
132
+ loader = ConfigLoader(str(config_dir))
133
+ repo = loader.repos["elprint-salescore"]
134
+ assert not hasattr(repo, "repo_name_field") # REPO_NAME property gone
135
+ assert not hasattr(repo, "package_prefixes") # PACKAGE_PREFIXES gone
136
+ assert not hasattr(repo, "cairn_mcp_enabled") # CAIRN_MCP_ENABLED gone
137
+ log = loader.log_sources["elprint-salescore"]
138
+ assert hasattr(log, "target_repo") # target_repo exists (used for routing)
@@ -0,0 +1,62 @@
1
+ """Tests for log_parser."""
2
+
3
+ import textwrap
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from sentinel.log_parser import parse_log_file, _fingerprint, _normalize_message
9
+
10
+
11
+ SAMPLE_LOG = textwrap.dedent("""\
12
+ 2024-01-15 12:00:00.000 INFO [main] com.example.App - Starting application
13
+ 2024-01-15 12:01:00.123 ERROR [http-nio-1] com.example.UserService - Failed to load user 42
14
+ \tat com.example.UserService.load(UserService.java:88)
15
+ \tat com.example.Controller.handle(Controller.java:55)
16
+ \t... 10 more
17
+ 2024-01-15 12:02:00.456 WARN [scheduler] com.example.Job - Job took too long
18
+ 2024-01-15 12:03:00.789 ERROR [http-nio-2] com.example.UserService - Failed to load user 99
19
+ \tat com.example.UserService.load(UserService.java:88)
20
+ \tat com.example.Controller.handle(Controller.java:55)
21
+ """)
22
+
23
+
24
+ @pytest.fixture
25
+ def log_file(tmp_path):
26
+ p = tmp_path / "app.log"
27
+ p.write_text(SAMPLE_LOG)
28
+ return p
29
+
30
+
31
+ def test_parse_events(log_file):
32
+ events = parse_log_file(log_file, "TEST")
33
+ # INFO is filtered out; we expect 2 ERRORs and 1 WARN
34
+ assert len(events) == 3
35
+ levels = {e.level for e in events}
36
+ assert "ERROR" in levels
37
+ assert "WARN" in levels
38
+
39
+
40
+ def test_stack_trace_attached(log_file):
41
+ events = parse_log_file(log_file, "TEST")
42
+ errors = [e for e in events if e.level == "ERROR"]
43
+ assert all(len(e.stack_trace) > 0 for e in errors)
44
+
45
+
46
+ def test_dedup_fingerprint(log_file):
47
+ events = parse_log_file(log_file, "TEST")
48
+ errors = [e for e in events if e.level == "ERROR"]
49
+ # Both errors have the same message pattern and stack → same fingerprint
50
+ assert errors[0].fingerprint == errors[1].fingerprint
51
+
52
+
53
+ def test_normalize_strips_ids():
54
+ msg = "Failed to load user 42 at 2024-01-15 12:00:00"
55
+ norm = _normalize_message(msg)
56
+ assert "42" not in norm
57
+ assert "TIMESTAMP" in norm
58
+
59
+
60
+ def test_source_name_attached(log_file):
61
+ events = parse_log_file(log_file, "MYSOURCE")
62
+ assert all(e.source == "MYSOURCE" for e in events)