@misterhuydo/sentinel 1.5.63 → 1.6.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/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
- package/python/sentinel/cairn_client.py +30 -11
- package/python/sentinel/fix_engine.py +200 -46
- package/python/sentinel/git_manager.py +335 -0
- package/python/sentinel/main.py +320 -6
- package/python/sentinel/state_store.py +121 -0
- package/python/tests/test_cairn_client.py +72 -0
- package/python/tests/test_fix_engine_cmd.py +53 -0
- package/python/tests/test_fix_engine_json.py +95 -0
- package/python/tests/test_fix_engine_prompt.py +93 -0
- package/python/tests/test_multi_repo_apply.py +254 -0
- package/python/tests/test_multi_repo_publish.py +175 -0
- package/python/tests/test_patch_parser.py +250 -0
- package/python/tests/test_project_lock.py +85 -0
- package/python/tests/test_state_store.py +87 -0
package/python/sentinel/main.py
CHANGED
|
@@ -20,10 +20,13 @@ import sys
|
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
-
from .cairn_client import ensure_installed as cairn_installed, index_repo
|
|
23
|
+
from .cairn_client import ensure_installed as cairn_installed, index_repo, init_project_root
|
|
24
24
|
from .config_loader import ConfigLoader, SentinelConfig, resolve_auto_commit, resolve_auto_release
|
|
25
25
|
from .fix_engine import generate_fix
|
|
26
|
-
from .git_manager import
|
|
26
|
+
from .git_manager import (
|
|
27
|
+
apply_and_commit, publish, apply_and_commit_multi, publish_multi,
|
|
28
|
+
_git_env, MissingToolError, MavenAuthError, poll_open_prs,
|
|
29
|
+
)
|
|
27
30
|
from .cicd_trigger import trigger as cicd_trigger
|
|
28
31
|
from .log_fetcher import fetch_all
|
|
29
32
|
from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
|
|
@@ -45,6 +48,18 @@ logger = logging.getLogger("sentinel")
|
|
|
45
48
|
|
|
46
49
|
_report_requested = False
|
|
47
50
|
|
|
51
|
+
# Per-project asyncio.Lock — serialises fix generation + apply within one
|
|
52
|
+
# project so two concurrent claude sessions never race on the working tree.
|
|
53
|
+
# Keyed by cfg.project_name; populated lazily on first acquire.
|
|
54
|
+
_project_locks: dict[str, "asyncio.Lock"] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _project_lock(project_name: str) -> "asyncio.Lock":
|
|
58
|
+
"""Return (and lazily create) the asyncio.Lock for a project."""
|
|
59
|
+
if project_name not in _project_locks:
|
|
60
|
+
_project_locks[project_name] = asyncio.Lock()
|
|
61
|
+
return _project_locks[project_name]
|
|
62
|
+
|
|
48
63
|
|
|
49
64
|
def _on_sigusr1(*_):
|
|
50
65
|
global _report_requested
|
|
@@ -271,10 +286,37 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
271
286
|
logger.debug("Fix already attempted recently for %s", event.fingerprint)
|
|
272
287
|
return
|
|
273
288
|
|
|
274
|
-
#
|
|
289
|
+
# Per-project lock — serialise fix generation+apply+publish so two
|
|
290
|
+
# concurrent claude sessions never race on the working tree of any repo.
|
|
291
|
+
async with _project_lock(sentinel.project_name or "_default"):
|
|
292
|
+
await _generate_apply_publish(
|
|
293
|
+
event, repo, sentinel, store, cfg_loader,
|
|
294
|
+
_progress, auto_commit, auto_release,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _generate_apply_publish(
|
|
299
|
+
event: ErrorEvent,
|
|
300
|
+
repo,
|
|
301
|
+
sentinel: SentinelConfig,
|
|
302
|
+
store: StateStore,
|
|
303
|
+
cfg_loader: ConfigLoader,
|
|
304
|
+
_progress,
|
|
305
|
+
auto_commit: bool,
|
|
306
|
+
auto_release: bool,
|
|
307
|
+
):
|
|
308
|
+
"""Generate the fix and apply it via the multi-repo flow (with single-repo fallback).
|
|
309
|
+
|
|
310
|
+
A patch from claude with `repos/<name>/...` paths goes through the
|
|
311
|
+
multi-repo apply (atomic dry-run, per-repo commit). If the patch has no
|
|
312
|
+
such prefix (legacy single-repo), we fall back to apply_and_commit/publish.
|
|
313
|
+
"""
|
|
275
314
|
_progress(":brain: Analyzing with Claude Code...")
|
|
276
315
|
patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
|
|
277
|
-
|
|
316
|
+
all_repos = list(cfg_loader.repos.values())
|
|
317
|
+
status, patch_path, marker = generate_fix(
|
|
318
|
+
event, repo, sentinel, patches_dir, store, all_repos=all_repos,
|
|
319
|
+
)
|
|
278
320
|
|
|
279
321
|
if status != "patch" or patch_path is None:
|
|
280
322
|
outcome = "skipped" if status in ("skip", "needs_human") else "failed"
|
|
@@ -295,8 +337,137 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
295
337
|
})
|
|
296
338
|
return
|
|
297
339
|
|
|
298
|
-
# ──
|
|
340
|
+
# ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
|
|
299
341
|
_progress(":gear: Applying patch and running tests...")
|
|
342
|
+
multi_results = apply_and_commit_multi(event, patch_path, all_repos, sentinel)
|
|
343
|
+
|
|
344
|
+
if not multi_results:
|
|
345
|
+
# Legacy single-repo patch (no `repos/<name>/` prefix) — use existing flow.
|
|
346
|
+
logger.info(
|
|
347
|
+
"main: patch for %s has no repos/<name>/ prefix — falling back to single-repo apply",
|
|
348
|
+
event.fingerprint[:8],
|
|
349
|
+
)
|
|
350
|
+
await _apply_publish_single(
|
|
351
|
+
event, repo, sentinel, store, cfg_loader,
|
|
352
|
+
_progress, auto_commit, auto_release, patch_path, marker,
|
|
353
|
+
)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# ── Multi-repo flow ────────────────────────────────────────────────────
|
|
357
|
+
multi_results = publish_multi(event, multi_results, sentinel)
|
|
358
|
+
|
|
359
|
+
committed = [r for r in multi_results if r["status"] == "committed"]
|
|
360
|
+
failed = [r for r in multi_results if r["status"] != "committed"]
|
|
361
|
+
|
|
362
|
+
# Record per-repo state in fix_repos for Boss/reporter visibility.
|
|
363
|
+
for r in multi_results:
|
|
364
|
+
pr_state = "open" if r.get("pr_url") else ("merged" if r["status"] == "committed" and auto_commit else "")
|
|
365
|
+
try:
|
|
366
|
+
store.record_fix_repo(
|
|
367
|
+
event.fingerprint, r["repo_name"],
|
|
368
|
+
branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
|
|
369
|
+
pr_url=r.get("pr_url", ""),
|
|
370
|
+
pr_state=pr_state if r["status"] == "committed" else r["status"],
|
|
371
|
+
apply_order=r.get("apply_order", 0),
|
|
372
|
+
)
|
|
373
|
+
except Exception as _se:
|
|
374
|
+
logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
|
|
375
|
+
|
|
376
|
+
# Summary fixes-table row uses the primary repo (where the error originated)
|
|
377
|
+
# so existing single-row queries (get_open_prs, recent fixes) still work.
|
|
378
|
+
primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
|
|
379
|
+
if primary and primary["status"] == "committed":
|
|
380
|
+
store.record_fix(
|
|
381
|
+
event.fingerprint,
|
|
382
|
+
"applied" if auto_commit else "pending",
|
|
383
|
+
patch_path=str(patch_path),
|
|
384
|
+
commit_hash=primary["commit_hash"],
|
|
385
|
+
branch=primary.get("branch", ""),
|
|
386
|
+
pr_url=primary.get("pr_url", ""),
|
|
387
|
+
repo_name=repo.repo_name,
|
|
388
|
+
sentinel_marker=marker,
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
392
|
+
|
|
393
|
+
# ── Surface the result ─────────────────────────────────────────────────
|
|
394
|
+
if not committed:
|
|
395
|
+
reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
|
|
396
|
+
_progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
|
|
397
|
+
send_failure_notification(sentinel, {
|
|
398
|
+
"source": event.source,
|
|
399
|
+
"message": event.message,
|
|
400
|
+
"repo_name": repo.repo_name,
|
|
401
|
+
"reason": f"Multi-repo apply failed: {reasons[:400]}",
|
|
402
|
+
"body": event.full_text()[:500],
|
|
403
|
+
})
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
if failed:
|
|
407
|
+
_progress(
|
|
408
|
+
f":warning: Multi-repo fix PARTIAL — committed in "
|
|
409
|
+
f"{', '.join(r['repo_name'] for r in committed)}; "
|
|
410
|
+
f"failed in {', '.join(r['repo_name'] for r in failed)}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
pr_lines = []
|
|
414
|
+
for r in committed:
|
|
415
|
+
if r.get("pr_url"):
|
|
416
|
+
pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
|
|
417
|
+
else:
|
|
418
|
+
pr_lines.append(f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
|
|
419
|
+
f"(`{r.get('commit_hash','')[:8]}`)")
|
|
420
|
+
if pr_lines:
|
|
421
|
+
_progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
|
|
422
|
+
|
|
423
|
+
# Pending release tracking for auto_commit-without-auto_release per repo.
|
|
424
|
+
if auto_commit and not auto_release:
|
|
425
|
+
for r in committed:
|
|
426
|
+
if r.get("commit_hash") and r.get("branch"):
|
|
427
|
+
store.add_pending_release(
|
|
428
|
+
r["repo_name"], r["commit_hash"], r["branch"],
|
|
429
|
+
description=event.message[:120],
|
|
430
|
+
fingerprint=event.fingerprint,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Single fix notification using the primary repo's row (back-compat with reporter).
|
|
434
|
+
if primary and primary["status"] == "committed":
|
|
435
|
+
send_fix_notification(sentinel, {
|
|
436
|
+
"source": event.source,
|
|
437
|
+
"severity": event.severity,
|
|
438
|
+
"fingerprint": event.fingerprint,
|
|
439
|
+
"first_seen": str(event.timestamp),
|
|
440
|
+
"message": event.message,
|
|
441
|
+
"stack_trace": getattr(event, "stack_trace", ""),
|
|
442
|
+
"repo_name": repo.repo_name,
|
|
443
|
+
"commit_hash": primary.get("commit_hash", ""),
|
|
444
|
+
"branch": primary.get("branch", ""),
|
|
445
|
+
"pr_url": primary.get("pr_url", ""),
|
|
446
|
+
"auto_commit": auto_commit,
|
|
447
|
+
"files_changed": [],
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
# CI/CD trigger — only fire for the primary repo for now (multi-repo cascade is v2).
|
|
451
|
+
if auto_commit and auto_release and primary and primary["status"] == "committed":
|
|
452
|
+
ok = cicd_trigger(repo, store, event.fingerprint)
|
|
453
|
+
if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
|
|
454
|
+
_run_cascade(repo, sentinel, cfg_loader)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def _apply_publish_single(
|
|
458
|
+
event: ErrorEvent,
|
|
459
|
+
repo,
|
|
460
|
+
sentinel: SentinelConfig,
|
|
461
|
+
store: StateStore,
|
|
462
|
+
cfg_loader: ConfigLoader,
|
|
463
|
+
_progress,
|
|
464
|
+
auto_commit: bool,
|
|
465
|
+
auto_release: bool,
|
|
466
|
+
patch_path: Path,
|
|
467
|
+
marker: str,
|
|
468
|
+
):
|
|
469
|
+
"""Legacy single-repo apply+publish flow — used when the patch has no
|
|
470
|
+
`repos/<name>/` prefix (older claude output, manual issues, etc.)."""
|
|
300
471
|
try:
|
|
301
472
|
commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
|
|
302
473
|
except MavenAuthError as e:
|
|
@@ -457,12 +628,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
457
628
|
try:
|
|
458
629
|
patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
|
|
459
630
|
_loop = asyncio.get_event_loop()
|
|
631
|
+
all_repos = list(cfg_loader.repos.values())
|
|
460
632
|
|
|
461
633
|
# Run blocking subprocess work in thread executor so Boss stays responsive.
|
|
462
634
|
# _progress is thread-safe (HTTP POST) so pass it as streaming callback.
|
|
463
635
|
_progress(":brain: Analyzing with Claude Code...")
|
|
464
636
|
status, patch_path, marker = await _loop.run_in_executor(
|
|
465
|
-
None,
|
|
637
|
+
None,
|
|
638
|
+
lambda: generate_fix(event, repo, sentinel, patches_dir, store,
|
|
639
|
+
_progress, all_repos),
|
|
466
640
|
)
|
|
467
641
|
|
|
468
642
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
@@ -481,6 +655,133 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
481
655
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
482
656
|
"status": "blocked", "summary": reason_text[:120], "pr_url": ""}
|
|
483
657
|
|
|
658
|
+
# ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
|
|
659
|
+
_progress(f":mag: Patch generated — applying & testing...")
|
|
660
|
+
multi_results = await _loop.run_in_executor(
|
|
661
|
+
None, apply_and_commit_multi, event, patch_path, all_repos, sentinel,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if multi_results:
|
|
665
|
+
multi_results = await _loop.run_in_executor(
|
|
666
|
+
None, publish_multi, event, multi_results, sentinel,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
committed = [r for r in multi_results if r["status"] == "committed"]
|
|
670
|
+
failed = [r for r in multi_results if r["status"] != "committed"]
|
|
671
|
+
|
|
672
|
+
for r in multi_results:
|
|
673
|
+
pr_state = "open" if r.get("pr_url") else (
|
|
674
|
+
"merged" if r["status"] == "committed" and auto_commit else ""
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
store.record_fix_repo(
|
|
678
|
+
event.fingerprint, r["repo_name"],
|
|
679
|
+
branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
|
|
680
|
+
pr_url=r.get("pr_url", ""),
|
|
681
|
+
pr_state=pr_state if r["status"] == "committed" else r["status"],
|
|
682
|
+
apply_order=r.get("apply_order", 0),
|
|
683
|
+
)
|
|
684
|
+
except Exception as _se:
|
|
685
|
+
logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
|
|
686
|
+
|
|
687
|
+
primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
|
|
688
|
+
if primary and primary["status"] == "committed":
|
|
689
|
+
store.record_fix(
|
|
690
|
+
event.fingerprint,
|
|
691
|
+
"applied" if auto_commit else "pending",
|
|
692
|
+
patch_path=str(patch_path),
|
|
693
|
+
commit_hash=primary["commit_hash"],
|
|
694
|
+
branch=primary.get("branch", ""),
|
|
695
|
+
pr_url=primary.get("pr_url", ""),
|
|
696
|
+
repo_name=repo.repo_name,
|
|
697
|
+
sentinel_marker=marker,
|
|
698
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
701
|
+
|
|
702
|
+
if not committed:
|
|
703
|
+
reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
|
|
704
|
+
_progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
|
|
705
|
+
notify_fix_blocked(sentinel, event.source, event.message,
|
|
706
|
+
reason=f"Multi-repo apply failed: {reasons[:400]}",
|
|
707
|
+
repo_name=repo.repo_name,
|
|
708
|
+
submitter_user_id=submitter_uid,
|
|
709
|
+
body=getattr(event, "body", ""))
|
|
710
|
+
mark_done(event.issue_file)
|
|
711
|
+
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
712
|
+
"status": "blocked", "summary": "Multi-repo apply failed", "pr_url": ""}
|
|
713
|
+
|
|
714
|
+
if failed:
|
|
715
|
+
_progress(
|
|
716
|
+
f":warning: Multi-repo fix PARTIAL — committed in "
|
|
717
|
+
f"{', '.join(r['repo_name'] for r in committed)}; "
|
|
718
|
+
f"failed in {', '.join(r['repo_name'] for r in failed)}"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
pr_lines = []
|
|
722
|
+
for r in committed:
|
|
723
|
+
if r.get("pr_url"):
|
|
724
|
+
pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
|
|
725
|
+
else:
|
|
726
|
+
pr_lines.append(
|
|
727
|
+
f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
|
|
728
|
+
f"(`{r.get('commit_hash','')[:8]}`)"
|
|
729
|
+
)
|
|
730
|
+
if pr_lines:
|
|
731
|
+
_progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
|
|
732
|
+
|
|
733
|
+
if auto_commit and not auto_release:
|
|
734
|
+
for r in committed:
|
|
735
|
+
if r.get("commit_hash") and r.get("branch"):
|
|
736
|
+
store.add_pending_release(
|
|
737
|
+
r["repo_name"], r["commit_hash"], r["branch"],
|
|
738
|
+
description=event.message[:120],
|
|
739
|
+
fingerprint=event.fingerprint,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if primary and primary["status"] == "committed":
|
|
743
|
+
send_fix_notification(sentinel, {
|
|
744
|
+
"source": event.source,
|
|
745
|
+
"severity": "ERROR",
|
|
746
|
+
"fingerprint": event.fingerprint,
|
|
747
|
+
"first_seen": event.timestamp,
|
|
748
|
+
"message": event.message,
|
|
749
|
+
"stack_trace": event.body,
|
|
750
|
+
"repo_name": repo.repo_name,
|
|
751
|
+
"commit_hash": primary.get("commit_hash", ""),
|
|
752
|
+
"branch": primary.get("branch", ""),
|
|
753
|
+
"pr_url": primary.get("pr_url", ""),
|
|
754
|
+
"auto_commit": auto_commit,
|
|
755
|
+
"files_changed": [],
|
|
756
|
+
})
|
|
757
|
+
notify_fix_applied(
|
|
758
|
+
sentinel, event.source, event.message,
|
|
759
|
+
repo_name=repo.repo_name,
|
|
760
|
+
branch=primary.get("branch", ""),
|
|
761
|
+
pr_url=primary.get("pr_url", ""),
|
|
762
|
+
submitter_user_id=submitter_uid,
|
|
763
|
+
origin_channel=_origin_channel,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
mark_done(event.issue_file)
|
|
767
|
+
|
|
768
|
+
if auto_commit and auto_release and primary and primary["status"] == "committed":
|
|
769
|
+
ok = cicd_trigger(repo, store, event.fingerprint)
|
|
770
|
+
if ok:
|
|
771
|
+
_progress(f":rocket: Release triggered via {repo.cicd_type}")
|
|
772
|
+
if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
|
|
773
|
+
_run_cascade(repo, sentinel, cfg_loader)
|
|
774
|
+
|
|
775
|
+
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
776
|
+
"status": "done" if not failed else "partial",
|
|
777
|
+
"summary": event.message[:120],
|
|
778
|
+
"pr_url": primary.get("pr_url", "") if primary else ""}
|
|
779
|
+
|
|
780
|
+
# Legacy single-repo patch (no `repos/<name>/` prefix) — existing flow below.
|
|
781
|
+
logger.info(
|
|
782
|
+
"_handle_issue: patch %s has no repos/<name>/ prefix — falling back to single-repo apply",
|
|
783
|
+
event.fingerprint[:8],
|
|
784
|
+
)
|
|
484
785
|
_progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
|
|
485
786
|
commit_status, commit_hash = await _loop.run_in_executor(
|
|
486
787
|
None, apply_and_commit, event, patch_path, repo, sentinel
|
|
@@ -823,6 +1124,19 @@ async def _startup_checks(cfg_loader: ConfigLoader) -> dict:
|
|
|
823
1124
|
if not cairn_installed():
|
|
824
1125
|
results["warnings"].append("Cairn not found — run: npm install -g @misterhuydo/cairn-mcp")
|
|
825
1126
|
|
|
1127
|
+
# Initialise the project-root .cairn/ so child sub-repos federate up to it.
|
|
1128
|
+
# Once the project root + every sub-repo has its own .cairn/, Claude can
|
|
1129
|
+
# see across all repos via Cairn's parent walk-up — required for multi-repo
|
|
1130
|
+
# fix generation to work.
|
|
1131
|
+
project_root = str(Path(cfg_loader.sentinel.workspace_dir).parent)
|
|
1132
|
+
if init_project_root(project_root):
|
|
1133
|
+
logger.info("Cairn federation root initialised at %s", project_root)
|
|
1134
|
+
else:
|
|
1135
|
+
results["warnings"].append(
|
|
1136
|
+
f"Cairn project-root init failed at {project_root} — multi-repo "
|
|
1137
|
+
"visibility may be degraded. See logs."
|
|
1138
|
+
)
|
|
1139
|
+
|
|
826
1140
|
for name, repo in cfg_loader.repos.items():
|
|
827
1141
|
local = Path(repo.local_path)
|
|
828
1142
|
if not local.exists():
|
|
@@ -87,6 +87,30 @@ class StateStore:
|
|
|
87
87
|
fingerprint TEXT, -- error/issue fingerprint that triggered it
|
|
88
88
|
committed_at TEXT NOT NULL
|
|
89
89
|
);
|
|
90
|
+
|
|
91
|
+
-- One fingerprint can produce changes in multiple repos (cross-repo fix).
|
|
92
|
+
-- Each row tracks the per-repo branch / commit / PR for one such fingerprint.
|
|
93
|
+
CREATE TABLE IF NOT EXISTS fix_repos (
|
|
94
|
+
fingerprint TEXT NOT NULL,
|
|
95
|
+
repo_name TEXT NOT NULL,
|
|
96
|
+
branch TEXT,
|
|
97
|
+
commit_hash TEXT,
|
|
98
|
+
pr_url TEXT,
|
|
99
|
+
pr_state TEXT, -- open|merged|closed|failed
|
|
100
|
+
apply_order INTEGER DEFAULT 0,
|
|
101
|
+
timestamp TEXT NOT NULL,
|
|
102
|
+
PRIMARY KEY (fingerprint, repo_name)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- Long-lived `claude --print --resume <id>` sessions, one per project.
|
|
106
|
+
-- Lets cross-task prompt cache hit and gives Claude continuous context.
|
|
107
|
+
CREATE TABLE IF NOT EXISTS claude_sessions (
|
|
108
|
+
project_name TEXT PRIMARY KEY,
|
|
109
|
+
session_id TEXT NOT NULL,
|
|
110
|
+
last_used TEXT NOT NULL,
|
|
111
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
112
|
+
turn_count INTEGER NOT NULL DEFAULT 0
|
|
113
|
+
);
|
|
90
114
|
""")
|
|
91
115
|
self._migrate()
|
|
92
116
|
logger.debug("StateStore initialised at %s", self.db_path)
|
|
@@ -276,6 +300,103 @@ class StateStore:
|
|
|
276
300
|
).fetchone()
|
|
277
301
|
return row is not None
|
|
278
302
|
|
|
303
|
+
# ── Multi-repo fix tracking (one fingerprint → many repos) ────────────────
|
|
304
|
+
|
|
305
|
+
def record_fix_repo(
|
|
306
|
+
self,
|
|
307
|
+
fingerprint: str,
|
|
308
|
+
repo_name: str,
|
|
309
|
+
branch: str = "",
|
|
310
|
+
commit_hash: str = "",
|
|
311
|
+
pr_url: str = "",
|
|
312
|
+
pr_state: str = "",
|
|
313
|
+
apply_order: int = 0,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Record (or replace) the per-repo branch/commit/PR for a multi-repo fix."""
|
|
316
|
+
with self._conn() as conn:
|
|
317
|
+
conn.execute(
|
|
318
|
+
"INSERT INTO fix_repos (fingerprint, repo_name, branch, commit_hash, "
|
|
319
|
+
"pr_url, pr_state, apply_order, timestamp) VALUES (?,?,?,?,?,?,?,?) "
|
|
320
|
+
"ON CONFLICT(fingerprint, repo_name) DO UPDATE SET "
|
|
321
|
+
"branch=excluded.branch, commit_hash=excluded.commit_hash, "
|
|
322
|
+
"pr_url=excluded.pr_url, pr_state=excluded.pr_state, "
|
|
323
|
+
"apply_order=excluded.apply_order, timestamp=excluded.timestamp",
|
|
324
|
+
(fingerprint, repo_name, branch, commit_hash,
|
|
325
|
+
pr_url, pr_state, apply_order, _now()),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def update_fix_repo_state(
|
|
329
|
+
self,
|
|
330
|
+
fingerprint: str,
|
|
331
|
+
repo_name: str,
|
|
332
|
+
pr_state: str = "",
|
|
333
|
+
commit_hash: str = "",
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Patch pr_state and/or commit_hash on an existing fix_repos row."""
|
|
336
|
+
sets, args = [], []
|
|
337
|
+
if pr_state:
|
|
338
|
+
sets.append("pr_state=?"); args.append(pr_state)
|
|
339
|
+
if commit_hash:
|
|
340
|
+
sets.append("commit_hash=?"); args.append(commit_hash)
|
|
341
|
+
if not sets:
|
|
342
|
+
return
|
|
343
|
+
args.extend([fingerprint, repo_name])
|
|
344
|
+
with self._conn() as conn:
|
|
345
|
+
conn.execute(
|
|
346
|
+
f"UPDATE fix_repos SET {', '.join(sets)} "
|
|
347
|
+
"WHERE fingerprint=? AND repo_name=?", args,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def get_fix_repos(self, fingerprint: str) -> list[dict]:
|
|
351
|
+
"""Return all per-repo rows for a fingerprint, ordered by apply_order then repo_name."""
|
|
352
|
+
with self._conn() as conn:
|
|
353
|
+
rows = conn.execute(
|
|
354
|
+
"SELECT * FROM fix_repos WHERE fingerprint=? "
|
|
355
|
+
"ORDER BY apply_order, repo_name",
|
|
356
|
+
(fingerprint,),
|
|
357
|
+
).fetchall()
|
|
358
|
+
return [dict(r) for r in rows]
|
|
359
|
+
|
|
360
|
+
# ── Per-project Claude session tracking (for `claude --resume <id>`) ──────
|
|
361
|
+
|
|
362
|
+
def get_claude_session(self, project_name: str) -> dict | None:
|
|
363
|
+
"""Return the saved {session_id, last_used, total_cost_usd, turn_count} or None."""
|
|
364
|
+
with self._conn() as conn:
|
|
365
|
+
row = conn.execute(
|
|
366
|
+
"SELECT session_id, last_used, total_cost_usd, turn_count "
|
|
367
|
+
"FROM claude_sessions WHERE project_name=?",
|
|
368
|
+
(project_name,),
|
|
369
|
+
).fetchone()
|
|
370
|
+
return dict(row) if row else None
|
|
371
|
+
|
|
372
|
+
def set_claude_session(
|
|
373
|
+
self,
|
|
374
|
+
project_name: str,
|
|
375
|
+
session_id: str,
|
|
376
|
+
cost_delta: float = 0.0,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Upsert the session id; accumulates cost + turn count across calls."""
|
|
379
|
+
with self._conn() as conn:
|
|
380
|
+
conn.execute(
|
|
381
|
+
"INSERT INTO claude_sessions "
|
|
382
|
+
"(project_name, session_id, last_used, total_cost_usd, turn_count) "
|
|
383
|
+
"VALUES (?, ?, ?, ?, 1) "
|
|
384
|
+
"ON CONFLICT(project_name) DO UPDATE SET "
|
|
385
|
+
"session_id=excluded.session_id, "
|
|
386
|
+
"last_used=excluded.last_used, "
|
|
387
|
+
"total_cost_usd=total_cost_usd + excluded.total_cost_usd, "
|
|
388
|
+
"turn_count=turn_count + 1",
|
|
389
|
+
(project_name, session_id, _now(), cost_delta),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def clear_claude_session(self, project_name: str) -> None:
|
|
393
|
+
"""Drop the saved session id for a project (forces a fresh session next call)."""
|
|
394
|
+
with self._conn() as conn:
|
|
395
|
+
conn.execute(
|
|
396
|
+
"DELETE FROM claude_sessions WHERE project_name=?",
|
|
397
|
+
(project_name,),
|
|
398
|
+
)
|
|
399
|
+
|
|
279
400
|
def mark_marker_seen(self, marker: str) -> dict | None:
|
|
280
401
|
"""Record that a SENTINEL marker appeared in production logs."""
|
|
281
402
|
with self._conn() as conn:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_cairn_client.py — Unit tests for cairn federation init.
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sentinel.cairn_client import init_project_root, index_repo
|
|
8
|
+
from sentinel.config_loader import RepoConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fake_completed(rc=0, stdout="", stderr=""):
|
|
12
|
+
m = MagicMock()
|
|
13
|
+
m.returncode = rc
|
|
14
|
+
m.stdout = stdout
|
|
15
|
+
m.stderr = stderr
|
|
16
|
+
return m
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── init_project_root ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def test_init_project_root_skips_when_marker_exists(tmp_path):
|
|
22
|
+
(tmp_path / ".cairn").mkdir()
|
|
23
|
+
(tmp_path / ".cairn" / ".cairn-project").touch()
|
|
24
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
25
|
+
ok = init_project_root(str(tmp_path))
|
|
26
|
+
assert ok is True
|
|
27
|
+
run.assert_not_called()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_init_project_root_runs_cairn_install_when_missing(tmp_path):
|
|
31
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
32
|
+
return_value=_fake_completed(rc=0)) as run:
|
|
33
|
+
ok = init_project_root(str(tmp_path))
|
|
34
|
+
assert ok is True
|
|
35
|
+
run.assert_called_once()
|
|
36
|
+
args, kwargs = run.call_args
|
|
37
|
+
assert args[0] == ["cairn", "install"]
|
|
38
|
+
assert kwargs["cwd"] == str(tmp_path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_init_project_root_returns_false_on_install_failure(tmp_path):
|
|
42
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
43
|
+
return_value=_fake_completed(rc=1, stderr="boom")):
|
|
44
|
+
ok = init_project_root(str(tmp_path))
|
|
45
|
+
assert ok is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_init_project_root_returns_false_on_subprocess_exception(tmp_path):
|
|
49
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
50
|
+
side_effect=FileNotFoundError("cairn not on PATH")):
|
|
51
|
+
ok = init_project_root(str(tmp_path))
|
|
52
|
+
assert ok is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_init_project_root_creates_nothing_when_dir_missing(tmp_path):
|
|
56
|
+
missing = tmp_path / "does-not-exist"
|
|
57
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
58
|
+
ok = init_project_root(str(missing))
|
|
59
|
+
assert ok is False
|
|
60
|
+
run.assert_not_called()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── index_repo (existing behaviour — guard against regression) ────────────────
|
|
64
|
+
|
|
65
|
+
def test_index_repo_skips_when_marker_exists(tmp_path):
|
|
66
|
+
(tmp_path / ".cairn").mkdir()
|
|
67
|
+
(tmp_path / ".cairn" / ".cairn-project").touch()
|
|
68
|
+
repo = RepoConfig(repo_name="r", local_path=str(tmp_path), branch="main")
|
|
69
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
70
|
+
ok = index_repo(repo)
|
|
71
|
+
assert ok is True
|
|
72
|
+
run.assert_not_called()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_fix_engine_cmd.py — Tests for _claude_cmd flag composition.
|
|
3
|
+
|
|
4
|
+
The OAuth path silently fast-failed in production until 1.6.1 because
|
|
5
|
+
_claude_cmd unconditionally passed --bare, which forces API-key-only auth
|
|
6
|
+
and rejects the OAuth attempt's keyless env. These tests pin the new
|
|
7
|
+
behaviour: --bare only when the API key is actually in the env.
|
|
8
|
+
"""
|
|
9
|
+
from sentinel.fix_engine import _claude_cmd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_default_includes_bare_for_backcompat():
|
|
13
|
+
"""Existing callers without an explicit flag still get --bare."""
|
|
14
|
+
cmd = _claude_cmd("claude", "hi")
|
|
15
|
+
assert "--bare" in cmd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_use_bare_false_drops_bare():
|
|
19
|
+
cmd = _claude_cmd("claude", "hi", use_bare=False)
|
|
20
|
+
assert "--bare" not in cmd
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_use_bare_true_keeps_bare():
|
|
24
|
+
cmd = _claude_cmd("claude", "hi", use_bare=True)
|
|
25
|
+
assert "--bare" in cmd
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_session_id_adds_resume():
|
|
29
|
+
cmd = _claude_cmd("claude", "hi", session_id="abc-123")
|
|
30
|
+
assert "--resume" in cmd
|
|
31
|
+
i = cmd.index("--resume")
|
|
32
|
+
assert cmd[i + 1] == "abc-123"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_no_session_id_skips_resume():
|
|
36
|
+
cmd = _claude_cmd("claude", "hi", session_id="")
|
|
37
|
+
assert "--resume" not in cmd
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_output_format_json_always_present():
|
|
41
|
+
cmd_a = _claude_cmd("claude", "hi", use_bare=True)
|
|
42
|
+
cmd_b = _claude_cmd("claude", "hi", use_bare=False)
|
|
43
|
+
for cmd in (cmd_a, cmd_b):
|
|
44
|
+
assert "--output-format" in cmd
|
|
45
|
+
i = cmd.index("--output-format")
|
|
46
|
+
assert cmd[i + 1] == "json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_print_and_prompt_are_last():
|
|
50
|
+
"""`--print <prompt>` must come last so all flags are interpreted."""
|
|
51
|
+
cmd = _claude_cmd("claude", "the actual prompt", use_bare=False)
|
|
52
|
+
assert cmd[-2] == "--print"
|
|
53
|
+
assert cmd[-1] == "the actual prompt"
|