@misterhuydo/sentinel 1.4.43 → 1.4.45

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-25T08:22:19.770Z
1
+ 2026-03-25T10:11:02.271Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T08:25:36.640Z",
3
- "checkpoint_at": "2026-03-25T08:25:36.642Z",
2
+ "message": "Auto-checkpoint at 2026-03-25T10:19:44.042Z",
3
+ "checkpoint_at": "2026-03-25T10:19:44.043Z",
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.43",
3
+ "version": "1.4.45",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -129,6 +129,29 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
129
129
  ]
130
130
  return "\n".join(lines_out)
131
131
 
132
+ def _fix_blank_context_lines(patch: str) -> str:
133
+ """
134
+ Ensure blank context lines inside hunks have a leading space.
135
+ Claude sometimes outputs empty lines in hunk bodies as bare '\\n'
136
+ instead of ' \\n', which causes git-apply to report 'corrupt patch'.
137
+ """
138
+ lines = patch.splitlines()
139
+ result = []
140
+ in_hunk = False
141
+ for line in lines:
142
+ if line.startswith("@@"):
143
+ in_hunk = True
144
+ result.append(line)
145
+ elif line.startswith(("diff ", "--- ", "+++ ", "index ")):
146
+ in_hunk = False
147
+ result.append(line)
148
+ elif in_hunk and line == "":
149
+ result.append(" ")
150
+ else:
151
+ result.append(line)
152
+ return "\n".join(result) + "\n"
153
+
154
+
132
155
  def _validate_patch(patch: str) -> tuple[bool, str]:
133
156
  files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
134
157
  lines_changed = len([
@@ -338,6 +361,8 @@ def generate_fix(
338
361
  logger.warning("No patch found in Claude output for %s", event.fingerprint)
339
362
  return "error", None, ""
340
363
 
364
+ patch = _fix_blank_context_lines(patch)
365
+
341
366
  ok, reason = _validate_patch(patch)
342
367
  if not ok:
343
368
  logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
@@ -906,6 +906,31 @@ _TOOLS = [
906
906
  ),
907
907
  "input_schema": {"type": "object", "properties": {}},
908
908
  },
909
+ {
910
+ "name": "merge_pr",
911
+ "description": (
912
+ "ADMIN ONLY. Merge an open Sentinel PR into the main branch. "
913
+ "Use when AUTO_PUBLISH=false and you are satisfied with the fix after review. "
914
+ "Handles rebase conflicts automatically if possible. "
915
+ "e.g. 'merge the fix for Whydah-TypeLib', 'sync fix abc123 to main', "
916
+ "'merge the open PR for elprint-sales-core-service'"
917
+ ),
918
+ "input_schema": {
919
+ "type": "object",
920
+ "properties": {
921
+ "repo_name": {
922
+ "type": "string",
923
+ "description": "Repository name (must match a repo in config/repos/)",
924
+ },
925
+ "fingerprint": {
926
+ "type": "string",
927
+ "description": "Optional 8-char fingerprint to target a specific fix PR. "
928
+ "If omitted, merges the most recent open PR for the repo.",
929
+ },
930
+ },
931
+ "required": ["repo_name"],
932
+ },
933
+ },
909
934
  {
910
935
  "name": "set_maintenance",
911
936
  "description": (
@@ -2168,7 +2193,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2168
2193
  return json.dumps({"error": "cannot determine user — not clearing"})
2169
2194
 
2170
2195
  # ── Admin-only tools ──────────────────────────────────────────────────────
2171
- _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db"}
2196
+ _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr"}
2172
2197
  if name in _ADMIN_TOOLS:
2173
2198
  if not is_admin:
2174
2199
  return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})
@@ -2178,9 +2203,9 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2178
2203
  return json.dumps({"users": stats, "total": len(stats)})
2179
2204
 
2180
2205
  if name == "clear_user_history":
2181
- target = inputs.get("target_user_id", "").strip()
2206
+ target = (inputs.get("user_id") or inputs.get("target_user_id", "")).strip()
2182
2207
  if not target:
2183
- return json.dumps({"error": "target_user_id is required"})
2208
+ return json.dumps({"error": "user_id is required"})
2184
2209
  store.save_conversation(target, [])
2185
2210
  display = store.get_user_name(target)
2186
2211
  logger.info("Boss admin: cleared history for user %s (%s) by admin %s", target, display, user_id)
@@ -2249,6 +2274,112 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2249
2274
  except Exception as e:
2250
2275
  return json.dumps({"error": str(e)})
2251
2276
 
2277
+ if name == "merge_pr":
2278
+ import re as _re
2279
+ import requests as _req
2280
+ repo_name = inputs.get("repo_name", "").strip()
2281
+ fingerprint = inputs.get("fingerprint", "").strip()
2282
+ github_token = cfg_loader.sentinel.github_token
2283
+ if not github_token:
2284
+ return json.dumps({"error": "GITHUB_TOKEN not configured"})
2285
+
2286
+ # Find the PR in state_store
2287
+ open_prs = store.get_open_prs()
2288
+ candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
2289
+ if fingerprint:
2290
+ candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
2291
+ if not candidates:
2292
+ return json.dumps({"error": f"No open Sentinel PR found for repo '{repo_name}'"
2293
+ + (f" with fingerprint '{fingerprint}'" if fingerprint else "")})
2294
+ fix = candidates[0] # most recent
2295
+ pr_url = fix.get("pr_url", "")
2296
+ branch = fix.get("branch", "")
2297
+ fp = fix.get("fingerprint", "")
2298
+
2299
+ # Parse owner/repo and PR number from pr_url
2300
+ m = _re.search(r"github\.com/([^/]+/[^/]+)/pull/(\d+)", pr_url)
2301
+ if not m:
2302
+ return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
2303
+ owner_repo = m.group(1)
2304
+ pr_number = m.group(2)
2305
+ headers = {
2306
+ "Authorization": f"Bearer {github_token}",
2307
+ "Accept": "application/vnd.github+json",
2308
+ }
2309
+
2310
+ # Attempt merge via GitHub API
2311
+ merge_resp = _req.put(
2312
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}/merge",
2313
+ json={"merge_method": "squash", "commit_title": f"fix(sentinel): merge PR #{pr_number}"},
2314
+ headers=headers, timeout=30,
2315
+ )
2316
+
2317
+ if merge_resp.status_code == 200:
2318
+ # Success
2319
+ sha = merge_resp.json().get("sha", "")[:8]
2320
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
2321
+ repo_name=repo_name, commit_hash=sha)
2322
+ logger.info("Boss merge_pr: merged PR #%s for %s (fp=%s) by admin %s",
2323
+ pr_number, repo_name, fp[:8], user_id)
2324
+ return json.dumps({
2325
+ "status": "merged",
2326
+ "pr": pr_url,
2327
+ "sha": sha,
2328
+ "repo": repo_name,
2329
+ "note": f"PR #{pr_number} merged into main. Branch '{branch}' can now be deleted.",
2330
+ })
2331
+
2332
+ # Conflict — attempt local rebase then retry
2333
+ if merge_resp.status_code in (405, 409):
2334
+ repo_cfg = cfg_loader.repos.get(repo_name)
2335
+ if not repo_cfg or not repo_cfg.local_path:
2336
+ return json.dumps({"error": f"Merge conflict and no local clone found for '{repo_name}'. Resolve manually: {pr_url}"})
2337
+ import subprocess as _sp
2338
+ from .git_manager import _git_env
2339
+ env = _git_env(repo_cfg)
2340
+ cwd = repo_cfg.local_path
2341
+ base = repo_cfg.branch
2342
+ # fetch + checkout fix branch + rebase
2343
+ _sp.run(["git", "fetch", "origin"], cwd=cwd, env=env, capture_output=True, timeout=60)
2344
+ _sp.run(["git", "checkout", branch], cwd=cwd, env=env, capture_output=True, timeout=30)
2345
+ rb = _sp.run(["git", "rebase", f"origin/{base}"], cwd=cwd, env=env, capture_output=True, timeout=60)
2346
+ if rb.returncode != 0:
2347
+ _sp.run(["git", "rebase", "--abort"], cwd=cwd, env=env, capture_output=True, timeout=30)
2348
+ _sp.run(["git", "checkout", base], cwd=cwd, env=env, capture_output=True, timeout=30)
2349
+ return json.dumps({
2350
+ "status": "conflict",
2351
+ "error": "Rebase failed — conflicts must be resolved manually",
2352
+ "pr": pr_url,
2353
+ "details": rb.stderr.strip()[:500],
2354
+ })
2355
+ _sp.run(["git", "push", "--force-with-lease", "origin", branch],
2356
+ cwd=cwd, env=env, capture_output=True, timeout=60)
2357
+ _sp.run(["git", "checkout", base], cwd=cwd, env=env, capture_output=True, timeout=30)
2358
+ # Retry merge
2359
+ retry_resp = _req.put(
2360
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}/merge",
2361
+ json={"merge_method": "squash", "commit_title": f"fix(sentinel): merge PR #{pr_number}"},
2362
+ headers=headers, timeout=30,
2363
+ )
2364
+ if retry_resp.status_code == 200:
2365
+ sha = retry_resp.json().get("sha", "")[:8]
2366
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
2367
+ repo_name=repo_name, commit_hash=sha)
2368
+ logger.info("Boss merge_pr: merged (after rebase) PR #%s for %s by admin %s",
2369
+ pr_number, repo_name, user_id)
2370
+ return json.dumps({
2371
+ "status": "merged",
2372
+ "pr": pr_url,
2373
+ "sha": sha,
2374
+ "repo": repo_name,
2375
+ "note": f"PR #{pr_number} merged after rebase. Branch '{branch}' can now be deleted.",
2376
+ })
2377
+ return json.dumps({"status": "error", "pr": pr_url,
2378
+ "error": f"Retry merge failed ({retry_resp.status_code}): {retry_resp.text[:300]}"})
2379
+
2380
+ return json.dumps({"status": "error", "pr": pr_url,
2381
+ "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
2382
+
2252
2383
  return json.dumps({"error": f"unknown tool: {name}"})
2253
2384
 
2254
2385