@misterhuydo/sentinel 1.4.66 → 1.4.68
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/package.json +21 -21
- package/python/sentinel/dependency_manager.py +206 -0
- package/python/sentinel/git_manager.py +113 -0
- package/python/sentinel/main.py +32 -4
- package/python/sentinel/notify.py +376 -331
- package/python/sentinel/sentinel_boss.py +306 -1
|
@@ -985,6 +985,11 @@ _TOOLS = [
|
|
|
985
985
|
"description": "Optional 8-char fingerprint to target a specific fix PR. "
|
|
986
986
|
"If omitted, merges the most recent open PR for the repo.",
|
|
987
987
|
},
|
|
988
|
+
"pr_number": {
|
|
989
|
+
"type": "integer",
|
|
990
|
+
"description": "Merge a specific PR by number (e.g. a Renovate PR). "
|
|
991
|
+
"When set, repo_name is still required but fingerprint is ignored.",
|
|
992
|
+
},
|
|
988
993
|
},
|
|
989
994
|
"required": ["repo_name"],
|
|
990
995
|
},
|
|
@@ -1008,6 +1013,72 @@ _TOOLS = [
|
|
|
1008
1013
|
"required": ["tool_name"],
|
|
1009
1014
|
},
|
|
1010
1015
|
},
|
|
1016
|
+
{
|
|
1017
|
+
"name": "list_renovate_prs",
|
|
1018
|
+
"description": (
|
|
1019
|
+
"List all open Renovate bot pull requests across all managed repos. "
|
|
1020
|
+
"Shows package name, version change, Renovate confidence, CI status, age, and merge-readiness. "
|
|
1021
|
+
"Use before deciding which Renovate PRs to merge. "
|
|
1022
|
+
"e.g. 'show renovate PRs', 'what dependency upgrades are pending?', "
|
|
1023
|
+
"'list open renovate pull requests', 'any renovate PRs ready to merge?'"
|
|
1024
|
+
),
|
|
1025
|
+
"input_schema": {
|
|
1026
|
+
"type": "object",
|
|
1027
|
+
"properties": {
|
|
1028
|
+
"repo_name": {
|
|
1029
|
+
"type": "string",
|
|
1030
|
+
"description": "Filter to a specific repo. Omit to scan all managed repos.",
|
|
1031
|
+
},
|
|
1032
|
+
"ready_only": {
|
|
1033
|
+
"type": "boolean",
|
|
1034
|
+
"description": "If true, only show PRs where CI passes and there are no conflicts.",
|
|
1035
|
+
"default": False,
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
"name": "manage_release",
|
|
1042
|
+
"description": (
|
|
1043
|
+
"Plan and execute repository operations: build, Maven release, dependency updates, "
|
|
1044
|
+
"or a full release-and-cascade (release one repo then bump its version in all dependents). "
|
|
1045
|
+
"Always presents a confirmation plan before acting. "
|
|
1046
|
+
"Examples: 'build Whydah-TypeLib', 'release Whydah-TypeLib', "
|
|
1047
|
+
"'update Whydah-Java-SDK to use the latest Whydah-TypeLib', "
|
|
1048
|
+
"'trigger a release for Whydah-TypeLib and update all dependents', "
|
|
1049
|
+
"'update all repos to use the latest whydah-typelib release'"
|
|
1050
|
+
),
|
|
1051
|
+
"input_schema": {
|
|
1052
|
+
"type": "object",
|
|
1053
|
+
"properties": {
|
|
1054
|
+
"operation": {
|
|
1055
|
+
"type": "string",
|
|
1056
|
+
"enum": ["build", "release", "update_deps", "release_and_cascade"],
|
|
1057
|
+
"description": (
|
|
1058
|
+
"build: trigger Jenkins build only. "
|
|
1059
|
+
"release: trigger Maven Release; auto-cascades if AUTO_PUBLISH=true. "
|
|
1060
|
+
"update_deps: update dependency version in target repos (source already released). "
|
|
1061
|
+
"release_and_cascade: release source_repo then cascade to all dependents."
|
|
1062
|
+
),
|
|
1063
|
+
},
|
|
1064
|
+
"source_repo": {
|
|
1065
|
+
"type": "string",
|
|
1066
|
+
"description": "Repo to build/release, or the repo whose artifact is being updated.",
|
|
1067
|
+
},
|
|
1068
|
+
"target_repos": {
|
|
1069
|
+
"type": "array",
|
|
1070
|
+
"items": {"type": "string"},
|
|
1071
|
+
"description": "Repos to update. Empty = auto-detect all dependents.",
|
|
1072
|
+
},
|
|
1073
|
+
"confirmed": {
|
|
1074
|
+
"type": "boolean",
|
|
1075
|
+
"description": "false = show plan and ask for confirmation. true = execute.",
|
|
1076
|
+
"default": False,
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
"required": ["operation", "source_repo"],
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1011
1082
|
{
|
|
1012
1083
|
"name": "set_maintenance",
|
|
1013
1084
|
"description": (
|
|
@@ -2329,7 +2400,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2329
2400
|
return json.dumps({"error": "cannot determine user — not clearing"})
|
|
2330
2401
|
|
|
2331
2402
|
# ── Admin-only tools ──────────────────────────────────────────────────────
|
|
2332
|
-
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool"}
|
|
2403
|
+
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release"}
|
|
2333
2404
|
if name in _ADMIN_TOOLS:
|
|
2334
2405
|
if not is_admin:
|
|
2335
2406
|
return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})
|
|
@@ -2554,6 +2625,240 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2554
2625
|
except _sp.TimeoutExpired:
|
|
2555
2626
|
return json.dumps({"error": f"Install timed out for '{tool_name}'"})
|
|
2556
2627
|
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
if name == "list_renovate_prs":
|
|
2631
|
+
import requests as _req
|
|
2632
|
+
from datetime import datetime, timezone
|
|
2633
|
+
|
|
2634
|
+
github_token = cfg_loader.sentinel.github_token
|
|
2635
|
+
if not github_token:
|
|
2636
|
+
return json.dumps({"error": "GITHUB_TOKEN not configured — cannot query GitHub API"})
|
|
2637
|
+
|
|
2638
|
+
filter_repo = inputs.get("repo_name", "").strip()
|
|
2639
|
+
ready_only = bool(inputs.get("ready_only", False))
|
|
2640
|
+
headers = {
|
|
2641
|
+
"Authorization": f"Bearer {github_token}",
|
|
2642
|
+
"Accept": "application/vnd.github+json",
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
def _owner_repo_from_url(url):
|
|
2646
|
+
if url.startswith("git@"):
|
|
2647
|
+
return url.split(":")[-1].removesuffix(".git")
|
|
2648
|
+
return "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
|
|
2649
|
+
|
|
2650
|
+
def _ci_status(owner_repo, sha):
|
|
2651
|
+
r = _req.get(
|
|
2652
|
+
f"https://api.github.com/repos/{owner_repo}/commits/{sha}/check-runs",
|
|
2653
|
+
headers=headers, params={"per_page": 20}, timeout=10,
|
|
2654
|
+
)
|
|
2655
|
+
if r.status_code != 200:
|
|
2656
|
+
return "unknown"
|
|
2657
|
+
runs = r.json().get("check_runs", [])
|
|
2658
|
+
if not runs:
|
|
2659
|
+
return "no checks"
|
|
2660
|
+
statuses = {run["conclusion"] for run in runs if run["status"] == "completed"}
|
|
2661
|
+
if "failure" in statuses or "cancelled" in statuses:
|
|
2662
|
+
return "failing"
|
|
2663
|
+
if all(s == "success" for s in statuses) and len(statuses) > 0:
|
|
2664
|
+
return "passing"
|
|
2665
|
+
return "pending"
|
|
2666
|
+
|
|
2667
|
+
def _parse_renovate_body(body):
|
|
2668
|
+
"""Extract package change table and confidence from Renovate PR body."""
|
|
2669
|
+
if not body:
|
|
2670
|
+
return [], "unknown"
|
|
2671
|
+
# Confidence line: "| ... | Confidence |" table header, data follows
|
|
2672
|
+
conf = "unknown"
|
|
2673
|
+
conf_m = __import__("re").search(r"\| *(low|moderate|high|neutral|very high) *\|", body, __import__("re").IGNORECASE)
|
|
2674
|
+
if conf_m:
|
|
2675
|
+
conf = conf_m.group(1).lower()
|
|
2676
|
+
# Package table rows: | package | X.Y → A.B |
|
|
2677
|
+
changes = __import__("re").findall(r"\[([^\]]+)\].*?(\d+[\.\d]*)\s*[→\-]+\s*(\d+[\.\d]*)", body)
|
|
2678
|
+
pkgs = [{"package": p, "from": f, "to": t} for p, f, t in changes[:5]]
|
|
2679
|
+
return pkgs, conf
|
|
2680
|
+
|
|
2681
|
+
all_prs = []
|
|
2682
|
+
repos_to_scan = {
|
|
2683
|
+
name: repo for name, repo in cfg_loader.repos.items()
|
|
2684
|
+
if (not filter_repo) or (filter_repo.lower() in name.lower())
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
for repo_name_k, repo in repos_to_scan.items():
|
|
2688
|
+
if not repo.repo_url:
|
|
2689
|
+
continue
|
|
2690
|
+
owner_repo = _owner_repo_from_url(repo.repo_url)
|
|
2691
|
+
try:
|
|
2692
|
+
r = _req.get(
|
|
2693
|
+
f"https://api.github.com/repos/{owner_repo}/pulls",
|
|
2694
|
+
headers=headers,
|
|
2695
|
+
params={"state": "open", "per_page": 50},
|
|
2696
|
+
timeout=15,
|
|
2697
|
+
)
|
|
2698
|
+
if r.status_code != 200:
|
|
2699
|
+
continue
|
|
2700
|
+
for pr in r.json():
|
|
2701
|
+
labels = [l["name"] for l in pr.get("labels", [])]
|
|
2702
|
+
if "renovate" not in labels:
|
|
2703
|
+
continue
|
|
2704
|
+
sha = pr["head"]["sha"]
|
|
2705
|
+
ci = _ci_status(owner_repo, sha)
|
|
2706
|
+
mergeable = pr.get("mergeable")
|
|
2707
|
+
conflict = (mergeable is False)
|
|
2708
|
+
pkgs, conf = _parse_renovate_body(pr.get("body", ""))
|
|
2709
|
+
created = pr.get("created_at", "")
|
|
2710
|
+
age_days = 0
|
|
2711
|
+
if created:
|
|
2712
|
+
try:
|
|
2713
|
+
dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
|
2714
|
+
age_days = (datetime.now(timezone.utc) - dt).days
|
|
2715
|
+
except Exception:
|
|
2716
|
+
pass
|
|
2717
|
+
ready = (ci == "passing" and not conflict)
|
|
2718
|
+
if ready_only and not ready:
|
|
2719
|
+
continue
|
|
2720
|
+
all_prs.append({
|
|
2721
|
+
"repo": repo_name_k,
|
|
2722
|
+
"pr_number": pr["number"],
|
|
2723
|
+
"title": pr["title"],
|
|
2724
|
+
"url": pr["html_url"],
|
|
2725
|
+
"packages": pkgs,
|
|
2726
|
+
"confidence": conf,
|
|
2727
|
+
"ci": ci,
|
|
2728
|
+
"conflict": conflict,
|
|
2729
|
+
"age_days": age_days,
|
|
2730
|
+
"ready_to_merge": ready,
|
|
2731
|
+
})
|
|
2732
|
+
except Exception as exc:
|
|
2733
|
+
logger.warning("list_renovate_prs: error scanning %s: %s", repo_name_k, exc)
|
|
2734
|
+
|
|
2735
|
+
all_prs.sort(key=lambda p: (0 if p["ready_to_merge"] else 1, p["repo"]))
|
|
2736
|
+
return json.dumps({
|
|
2737
|
+
"total": len(all_prs),
|
|
2738
|
+
"ready_count": sum(1 for p in all_prs if p["ready_to_merge"]),
|
|
2739
|
+
"prs": all_prs,
|
|
2740
|
+
"note": "Use merge_pr with repo_name + pr_number to merge a specific PR." if all_prs else "No open Renovate PRs found.",
|
|
2741
|
+
})
|
|
2742
|
+
|
|
2743
|
+
if name == "manage_release":
|
|
2744
|
+
from .dependency_manager import plan_cascade, execute_cascade, get_artifact_id, get_release_version
|
|
2745
|
+
from .cicd_trigger import trigger as cicd_trigger, _trigger_jenkins, _trigger_jenkins_release
|
|
2746
|
+
from .notify import notify_cascade_started, notify_cascade_result
|
|
2747
|
+
|
|
2748
|
+
operation = inputs.get("operation", "").strip()
|
|
2749
|
+
source_repo = inputs.get("source_repo", "").strip()
|
|
2750
|
+
target_repos = inputs.get("target_repos") or []
|
|
2751
|
+
confirmed = bool(inputs.get("confirmed", False))
|
|
2752
|
+
|
|
2753
|
+
repo = cfg_loader.repos.get(source_repo)
|
|
2754
|
+
if not repo:
|
|
2755
|
+
# fuzzy match
|
|
2756
|
+
for rname in cfg_loader.repos:
|
|
2757
|
+
if source_repo.lower() in rname.lower():
|
|
2758
|
+
repo = cfg_loader.repos[rname]
|
|
2759
|
+
source_repo = rname
|
|
2760
|
+
break
|
|
2761
|
+
if not repo:
|
|
2762
|
+
return json.dumps({"error": f"Repo not found: {source_repo}. Known repos: {list(cfg_loader.repos.keys())}"})
|
|
2763
|
+
|
|
2764
|
+
# ── Plan phase (confirmed=false) ──────────────────────────────────────
|
|
2765
|
+
if not confirmed:
|
|
2766
|
+
if operation == "build":
|
|
2767
|
+
return json.dumps({
|
|
2768
|
+
"plan": f"Trigger Jenkins build for {source_repo}",
|
|
2769
|
+
"job_url": repo.cicd_job_url,
|
|
2770
|
+
"note": "This triggers a regular build, not a release.",
|
|
2771
|
+
"confirm_prompt": "Reply with confirmed=true to proceed.",
|
|
2772
|
+
})
|
|
2773
|
+
|
|
2774
|
+
if operation in ("release", "release_and_cascade"):
|
|
2775
|
+
cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_repos or None)
|
|
2776
|
+
if "error" in cascade_plan:
|
|
2777
|
+
return json.dumps(cascade_plan)
|
|
2778
|
+
cascade_note = (
|
|
2779
|
+
f"After release, will update {len(cascade_plan['dependents'])} dependent repo(s)."
|
|
2780
|
+
if cascade_plan["dependents"] else "No dependent repos found in config."
|
|
2781
|
+
)
|
|
2782
|
+
return json.dumps({
|
|
2783
|
+
"plan": f"Trigger Maven Release for {source_repo}",
|
|
2784
|
+
"release_version": cascade_plan["new_version"],
|
|
2785
|
+
"dev_version_after": cascade_plan.get("new_version", ""),
|
|
2786
|
+
"job_url": repo.cicd_job_url,
|
|
2787
|
+
"cascade": cascade_plan["dependents"],
|
|
2788
|
+
"cascade_note": cascade_note,
|
|
2789
|
+
"auto_publish_source": repo.auto_publish,
|
|
2790
|
+
"confirm_prompt": "Reply with confirmed=true to proceed.",
|
|
2791
|
+
})
|
|
2792
|
+
|
|
2793
|
+
if operation == "update_deps":
|
|
2794
|
+
cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_repos or None)
|
|
2795
|
+
if "error" in cascade_plan:
|
|
2796
|
+
return json.dumps(cascade_plan)
|
|
2797
|
+
if not cascade_plan["dependents"]:
|
|
2798
|
+
return json.dumps({"note": f"No repos depend on {cascade_plan['artifact_id']} — nothing to update."})
|
|
2799
|
+
return json.dumps({
|
|
2800
|
+
"plan": f"Update {cascade_plan['artifact_id']} to {cascade_plan['new_version']} in dependent repos",
|
|
2801
|
+
"artifact_id": cascade_plan["artifact_id"],
|
|
2802
|
+
"new_version": cascade_plan["new_version"],
|
|
2803
|
+
"targets": cascade_plan["dependents"],
|
|
2804
|
+
"confirm_prompt": "Reply with confirmed=true to proceed.",
|
|
2805
|
+
})
|
|
2806
|
+
|
|
2807
|
+
# ── Execute phase (confirmed=true) ────────────────────────────────────
|
|
2808
|
+
if operation == "build":
|
|
2809
|
+
success = _trigger_jenkins(repo)
|
|
2810
|
+
logger.info("Boss manage_release: build triggered for %s by %s", source_repo, user_id)
|
|
2811
|
+
return json.dumps({"status": "triggered" if success else "failed", "repo": source_repo, "job_url": repo.cicd_job_url})
|
|
2812
|
+
|
|
2813
|
+
if operation in ("release", "release_and_cascade"):
|
|
2814
|
+
success = _trigger_jenkins_release(repo)
|
|
2815
|
+
logger.info("Boss manage_release: release triggered for %s by %s (success=%s)", source_repo, user_id, success)
|
|
2816
|
+
if not success:
|
|
2817
|
+
return json.dumps({"status": "failed", "repo": source_repo, "error": "Jenkins release trigger failed — check logs"})
|
|
2818
|
+
|
|
2819
|
+
# Cascade immediately if release_and_cascade, or if AUTO_PUBLISH=true
|
|
2820
|
+
do_cascade = (operation == "release_and_cascade") or repo.auto_publish
|
|
2821
|
+
if do_cascade:
|
|
2822
|
+
artifact_id = get_artifact_id(repo.local_path)
|
|
2823
|
+
new_version = get_release_version(repo.local_path)
|
|
2824
|
+
if artifact_id and new_version:
|
|
2825
|
+
target_names = target_repos or None
|
|
2826
|
+
cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_names)
|
|
2827
|
+
target_repo_names = [d["repo"] for d in cascade_plan.get("dependents", [])]
|
|
2828
|
+
if target_repo_names:
|
|
2829
|
+
notify_cascade_started(cfg_loader.sentinel, artifact_id, new_version, target_repo_names, user_id)
|
|
2830
|
+
results = execute_cascade(source_repo, new_version, artifact_id, cfg_loader.repos, cfg_loader.sentinel, target_names)
|
|
2831
|
+
notify_cascade_result(cfg_loader.sentinel, artifact_id, new_version, results, user_id)
|
|
2832
|
+
return json.dumps({
|
|
2833
|
+
"status": "released_and_cascaded",
|
|
2834
|
+
"repo": source_repo,
|
|
2835
|
+
"version": new_version,
|
|
2836
|
+
"cascade": [{"repo": r.repo_name, "success": r.success, "pr_url": r.pr_url, "error": r.error} for r in results],
|
|
2837
|
+
})
|
|
2838
|
+
return json.dumps({"status": "released", "repo": source_repo, "note": "Cascade will run automatically on next poll if AUTO_PUBLISH=true."})
|
|
2839
|
+
|
|
2840
|
+
if operation == "update_deps":
|
|
2841
|
+
artifact_id = get_artifact_id(repo.local_path)
|
|
2842
|
+
new_version = get_release_version(repo.local_path)
|
|
2843
|
+
if not artifact_id or not new_version:
|
|
2844
|
+
return json.dumps({"error": f"Could not read artifact/version from {source_repo}/pom.xml"})
|
|
2845
|
+
target_names = target_repos or None
|
|
2846
|
+
cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_names)
|
|
2847
|
+
target_repo_names = [d["repo"] for d in cascade_plan.get("dependents", [])]
|
|
2848
|
+
if not target_repo_names:
|
|
2849
|
+
return json.dumps({"note": f"No repos depend on {artifact_id} — nothing to update."})
|
|
2850
|
+
notify_cascade_started(cfg_loader.sentinel, artifact_id, new_version, target_repo_names, user_id)
|
|
2851
|
+
results = execute_cascade(source_repo, new_version, artifact_id, cfg_loader.repos, cfg_loader.sentinel, target_names)
|
|
2852
|
+
notify_cascade_result(cfg_loader.sentinel, artifact_id, new_version, results, user_id)
|
|
2853
|
+
return json.dumps({
|
|
2854
|
+
"status": "updated",
|
|
2855
|
+
"artifact_id": artifact_id,
|
|
2856
|
+
"version": new_version,
|
|
2857
|
+
"results": [{"repo": r.repo_name, "success": r.success, "pr_url": r.pr_url, "error": r.error} for r in results],
|
|
2858
|
+
})
|
|
2859
|
+
|
|
2860
|
+
return json.dumps({"error": f"Unknown operation: {operation}"})
|
|
2861
|
+
|
|
2557
2862
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
2558
2863
|
|
|
2559
2864
|
|