@misterhuydo/sentinel 1.4.36 → 1.4.38

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/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-03-25T06:47:22.703Z
1
+ 2026-03-25T07:20:08.738Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T07:07:44.746Z",
3
- "checkpoint_at": "2026-03-25T07:07:44.747Z",
2
+ "message": "Auto-checkpoint at 2026-03-25T07:35:25.214Z",
3
+ "checkpoint_at": "2026-03-25T07:35:25.219Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.36",
3
+ "version": "1.4.38",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -29,6 +29,25 @@ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
29
29
  _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
30
30
 
31
31
 
32
+ def _extract_patch(output: str) -> str | None:
33
+ """Extract a unified diff patch from Claude's output."""
34
+ # 1. Prefer a fenced ```diff or ```patch block
35
+ m = _DIFF_BLOCK.search(output)
36
+ if m:
37
+ patch = m.group(1).strip()
38
+ if _DIFF_HEADER.search(patch):
39
+ return patch
40
+
41
+ # 2. Fall back to raw diff lines anywhere in the output
42
+ if _DIFF_HEADER.search(output):
43
+ lines = output.splitlines()
44
+ for i, line in enumerate(lines):
45
+ if _DIFF_HEADER.match(line):
46
+ return "\n".join(lines[i:])
47
+
48
+ return None
49
+
50
+
32
51
  def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers: list[str] = None, synced_files: list = None) -> str:
33
52
  if log_file and log_file.exists():
34
53
  ctx = (
@@ -29,7 +29,7 @@ from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
29
29
  from .issue_watcher import scan_issues, mark_done, IssueEvent
30
30
  from .repo_router import route
31
31
  from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification, send_upgrade_notification
32
- from .notify import notify_fix_blocked
32
+ from .notify import notify_fix_blocked, notify_fix_applied
33
33
  from .health_checker import evaluate_repos
34
34
  from .state_store import StateStore
35
35
 
@@ -183,61 +183,71 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
183
183
  )
184
184
  return # Leave the file so admin can add the header
185
185
 
186
- patches_dir = Path(sentinel.workspace_dir) / "patches"
187
- status, patch_path, marker = generate_fix(event, repo, sentinel, patches_dir, store)
186
+ try:
187
+ patches_dir = Path(sentinel.workspace_dir) / "patches"
188
+ status, patch_path, marker = generate_fix(event, repo, sentinel, patches_dir, store)
189
+
190
+ if status != "patch" or patch_path is None:
191
+ store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
192
+ repo_name=repo.repo_name)
193
+ # For user-submitted issues: always notify (person is waiting)
194
+ submitter_uid = getattr(event, "submitter_user_id", "")
195
+ reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
196
+ notify_fix_blocked(sentinel, event.source, event.message,
197
+ reason=reason_text, repo_name=repo.repo_name,
198
+ submitter_user_id=submitter_uid)
199
+ mark_done(event.issue_file)
200
+ return
188
201
 
189
- if status != "patch" or patch_path is None:
190
- store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
191
- repo_name=repo.repo_name)
192
- # For user-submitted issues: always notify (person is waiting)
193
- submitter_uid = getattr(event, "submitter_user_id", "")
194
- reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
195
- notify_fix_blocked(sentinel, event.source, event.message,
196
- reason=reason_text, repo_name=repo.repo_name,
197
- submitter_user_id=submitter_uid)
198
- mark_done(event.issue_file)
199
- return
202
+ commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
203
+ if commit_status != "committed":
204
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
205
+ submitter_uid = getattr(event, "submitter_user_id", "")
206
+ notify_fix_blocked(sentinel, event.source, event.message,
207
+ reason="Patch was generated but commit/tests failed",
208
+ repo_name=repo.repo_name,
209
+ submitter_user_id=submitter_uid)
210
+ mark_done(event.issue_file)
211
+ return
200
212
 
201
- commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
202
- if commit_status != "committed":
203
- store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
213
+ branch, pr_url = publish(event, repo, sentinel, commit_hash)
214
+ store.record_fix(
215
+ event.fingerprint,
216
+ "applied" if repo.auto_publish else "pending",
217
+ patch_path=str(patch_path),
218
+ commit_hash=commit_hash,
219
+ branch=branch,
220
+ pr_url=pr_url,
221
+ repo_name=repo.repo_name,
222
+ sentinel_marker=marker,
223
+ )
224
+ send_fix_notification(sentinel, {
225
+ "source": event.source,
226
+ "severity": "ERROR",
227
+ "fingerprint": event.fingerprint,
228
+ "first_seen": event.timestamp,
229
+ "message": event.message,
230
+ "stack_trace": event.body,
231
+ "repo_name": repo.repo_name,
232
+ "commit_hash": commit_hash,
233
+ "branch": branch,
234
+ "pr_url": pr_url,
235
+ "auto_publish": repo.auto_publish,
236
+ "files_changed": [],
237
+ })
204
238
  submitter_uid = getattr(event, "submitter_user_id", "")
205
- notify_fix_blocked(sentinel, event.source, event.message,
206
- reason="Patch was generated but commit/tests failed",
207
- repo_name=repo.repo_name,
239
+ notify_fix_applied(sentinel, event.source, event.message,
240
+ repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
208
241
  submitter_user_id=submitter_uid)
209
242
  mark_done(event.issue_file)
210
- return
211
243
 
212
- branch, pr_url = publish(event, repo, sentinel, commit_hash)
213
- store.record_fix(
214
- event.fingerprint,
215
- "applied" if repo.auto_publish else "pending",
216
- patch_path=str(patch_path),
217
- commit_hash=commit_hash,
218
- branch=branch,
219
- pr_url=pr_url,
220
- repo_name=repo.repo_name,
221
- sentinel_marker=marker,
222
- )
223
- send_fix_notification(sentinel, {
224
- "source": event.source,
225
- "severity": "ERROR",
226
- "fingerprint": event.fingerprint,
227
- "first_seen": event.timestamp,
228
- "message": event.message,
229
- "stack_trace": event.body,
230
- "repo_name": repo.repo_name,
231
- "commit_hash": commit_hash,
232
- "branch": branch,
233
- "pr_url": pr_url,
234
- "auto_publish": repo.auto_publish,
235
- "files_changed": [],
236
- })
237
- mark_done(event.issue_file)
244
+ if repo.auto_publish:
245
+ cicd_trigger(repo, store, event.fingerprint)
238
246
 
239
- if repo.auto_publish:
240
- cicd_trigger(repo, store, event.fingerprint)
247
+ except Exception:
248
+ logger.exception("Unexpected error processing issue %s — archiving to prevent retry loop", event.source)
249
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
250
+ mark_done(event.issue_file)
241
251
 
242
252
 
243
253
  async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
@@ -228,6 +228,39 @@ def notify_fix_blocked(
228
228
  logger.warning("notify_fix_blocked: email notification failed: %s", exc)
229
229
 
230
230
 
231
+ def notify_fix_applied(
232
+ cfg,
233
+ source: str,
234
+ message: str,
235
+ repo_name: str,
236
+ branch: str,
237
+ pr_url: str,
238
+ submitter_user_id: str = "",
239
+ ) -> None:
240
+ """
241
+ DM the submitter (if known) that their issue was fixed.
242
+ Falls back to posting in the Slack channel if no submitter.
243
+ """
244
+ repo_line = f" in *{repo_name}*" if repo_name else ""
245
+ if pr_url:
246
+ action_line = f":arrow_right: <{pr_url}|Review PR>"
247
+ elif branch:
248
+ action_line = f":arrow_right: Pushed to `{branch}`"
249
+ else:
250
+ action_line = ""
251
+
252
+ slack_text = (
253
+ f":white_check_mark: *Fix applied{repo_line}*\n"
254
+ f"*Issue:* {message[:200]}\n"
255
+ + (f"{action_line}\n" if action_line else "")
256
+ ).rstrip()
257
+
258
+ if submitter_user_id:
259
+ slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
260
+ else:
261
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, slack_text)
262
+
263
+
231
264
  def alert_if_rate_limited(
232
265
  bot_token: str,
233
266
  channel: str,