@misterhuydo/sentinel 1.4.68 → 1.4.70
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/lib/.cairn/minify-map.json +3 -3
- package/lib/.cairn/views/fb78ac_upgrade.js +16 -1
- package/lib/add.js +30 -16
- package/lib/generate.js +6 -1
- package/package.json +1 -1
- package/python/scripts/fix_ask_codebase_context.py +249 -0
- package/python/scripts/fix_ask_codebase_stdin.py +49 -0
- package/python/scripts/fix_chain_slack.py +67 -0
- package/python/scripts/fix_fstring.py +51 -0
- package/python/scripts/fix_knowledge_cache.py +323 -0
- package/python/scripts/fix_knowledge_cache_staleness.py +294 -0
- package/python/scripts/fix_merge_confirm.py +295 -0
- package/python/scripts/fix_permission_messages.py +78 -0
- package/python/scripts/fix_pr_check_head_detect.py +84 -0
- package/python/scripts/fix_pr_msg_newlines.py +57 -0
- package/python/scripts/fix_pr_tracking_boss.py +265 -0
- package/python/scripts/fix_pr_tracking_db.py +212 -0
- package/python/scripts/fix_pr_tracking_main.py +174 -0
- package/python/scripts/fix_project_isolation.py +197 -0
- package/python/scripts/fix_system_prompt.py +444 -0
- package/python/scripts/fix_two_bugs.py +220 -0
- package/python/scripts/patch_chain_release.py +236 -0
- package/python/sentinel/cicd_trigger.py +125 -16
- package/python/sentinel/dependency_manager.py +129 -18
- package/python/sentinel/git_manager.py +46 -12
- package/python/sentinel/notify.py +34 -0
- package/python/sentinel/sentinel_boss.py +4139 -3326
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Fix two bugs:
|
|
4
|
+
1. merge_pr: pr_number param ignored — now uses GitHub API directly for non-state-store PRs
|
|
5
|
+
2. slack_bot: thread replies with file_share subtype silently dropped
|
|
6
|
+
"""
|
|
7
|
+
import ast, sys
|
|
8
|
+
|
|
9
|
+
# ── Fix 1: merge_pr handler — honour pr_number ────────────────────────────────
|
|
10
|
+
|
|
11
|
+
BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
|
|
12
|
+
|
|
13
|
+
with open(BOSS, 'r', encoding='utf-8') as f:
|
|
14
|
+
boss = f.read()
|
|
15
|
+
|
|
16
|
+
OLD_MERGE = ''' if name == "merge_pr":
|
|
17
|
+
import re as _re
|
|
18
|
+
import requests as _req
|
|
19
|
+
repo_name = inputs.get("repo_name", "").strip()
|
|
20
|
+
fingerprint = inputs.get("fingerprint", "").strip()
|
|
21
|
+
github_token = cfg_loader.sentinel.github_token
|
|
22
|
+
if not github_token:
|
|
23
|
+
return json.dumps({"error": "GITHUB_TOKEN not configured"})
|
|
24
|
+
|
|
25
|
+
# Find the PR in state_store
|
|
26
|
+
open_prs = store.get_open_prs()
|
|
27
|
+
candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
|
|
28
|
+
if fingerprint:
|
|
29
|
+
candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
|
|
30
|
+
if not candidates:
|
|
31
|
+
return json.dumps({"error": f"No open Sentinel PR found for repo \'{repo_name}\'"
|
|
32
|
+
+ (f" with fingerprint \'{fingerprint}\'" if fingerprint else "")})
|
|
33
|
+
fix = candidates[0] # most recent
|
|
34
|
+
pr_url = fix.get("pr_url", "")
|
|
35
|
+
branch = fix.get("branch", "")
|
|
36
|
+
fp = fix.get("fingerprint", "")
|
|
37
|
+
|
|
38
|
+
# Parse owner/repo and PR number from pr_url
|
|
39
|
+
m = _re.search(r"github\\.com/([^/]+/[^/]+)/pull/(\\d+)", pr_url)
|
|
40
|
+
if not m:
|
|
41
|
+
return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
|
|
42
|
+
owner_repo = m.group(1)
|
|
43
|
+
pr_number = m.group(2)
|
|
44
|
+
headers = {
|
|
45
|
+
"Authorization": f"Bearer {github_token}",
|
|
46
|
+
"Accept": "application/vnd.github+json",
|
|
47
|
+
}'''
|
|
48
|
+
|
|
49
|
+
NEW_MERGE = ''' if name == "merge_pr":
|
|
50
|
+
import re as _re
|
|
51
|
+
import requests as _req
|
|
52
|
+
repo_name = inputs.get("repo_name", "").strip()
|
|
53
|
+
fingerprint = inputs.get("fingerprint", "").strip()
|
|
54
|
+
pr_number_in = inputs.get("pr_number") # explicit PR number (e.g. Renovate PR)
|
|
55
|
+
github_token = cfg_loader.sentinel.github_token
|
|
56
|
+
if not github_token:
|
|
57
|
+
return json.dumps({"error": "GITHUB_TOKEN not configured"})
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"Authorization": f"Bearer {github_token}",
|
|
61
|
+
"Accept": "application/vnd.github+json",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Fuzzy-match repo name against known configs
|
|
65
|
+
if repo_name not in cfg_loader.repos:
|
|
66
|
+
for rname in cfg_loader.repos:
|
|
67
|
+
if repo_name.lower() in rname.lower():
|
|
68
|
+
repo_name = rname
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
# ── Path A: explicit pr_number — merge directly via GitHub API ────
|
|
72
|
+
if pr_number_in:
|
|
73
|
+
pr_number_in = int(pr_number_in)
|
|
74
|
+
# Determine owner/repo from config or state_store
|
|
75
|
+
repo_cfg = cfg_loader.repos.get(repo_name)
|
|
76
|
+
owner_repo = ""
|
|
77
|
+
if repo_cfg and repo_cfg.repo_url:
|
|
78
|
+
url = repo_cfg.repo_url
|
|
79
|
+
if url.startswith("git@"):
|
|
80
|
+
owner_repo = url.split(":")[-1].removesuffix(".git")
|
|
81
|
+
else:
|
|
82
|
+
owner_repo = "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
|
|
83
|
+
if not owner_repo:
|
|
84
|
+
# Fall back: search state_store PRs for this repo to get owner/repo
|
|
85
|
+
for p in store.get_open_prs():
|
|
86
|
+
if p.get("repo_name") == repo_name and p.get("pr_url"):
|
|
87
|
+
m2 = _re.search(r"github\\.com/([^/]+/[^/]+)/pull/", p["pr_url"])
|
|
88
|
+
if m2:
|
|
89
|
+
owner_repo = m2.group(1)
|
|
90
|
+
break
|
|
91
|
+
if not owner_repo:
|
|
92
|
+
return json.dumps({"error": f"Cannot determine GitHub owner/repo for \'{repo_name}\'"})
|
|
93
|
+
|
|
94
|
+
# Fetch PR details to confirm it\'s open
|
|
95
|
+
pr_resp = _req.get(
|
|
96
|
+
f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number_in}",
|
|
97
|
+
headers=headers, timeout=15,
|
|
98
|
+
)
|
|
99
|
+
if pr_resp.status_code == 404:
|
|
100
|
+
return json.dumps({"error": f"PR #{pr_number_in} not found in {owner_repo}"})
|
|
101
|
+
if pr_resp.status_code != 200:
|
|
102
|
+
return json.dumps({"error": f"GitHub API error ({pr_resp.status_code}): {pr_resp.text[:200]}"})
|
|
103
|
+
pr_data = pr_resp.json()
|
|
104
|
+
if pr_data.get("state") != "open":
|
|
105
|
+
return json.dumps({"error": f"PR #{pr_number_in} is already {pr_data.get('state')} — nothing to merge"})
|
|
106
|
+
pr_url = pr_data.get("html_url", "")
|
|
107
|
+
branch = pr_data.get("head", {}).get("ref", "")
|
|
108
|
+
pr_title = pr_data.get("title", "")
|
|
109
|
+
|
|
110
|
+
merge_resp = _req.put(
|
|
111
|
+
f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number_in}/merge",
|
|
112
|
+
json={"merge_method": "squash", "commit_title": f"chore: merge PR #{pr_number_in} [{pr_title[:60]}]"},
|
|
113
|
+
headers=headers, timeout=30,
|
|
114
|
+
)
|
|
115
|
+
if merge_resp.status_code == 200:
|
|
116
|
+
sha = merge_resp.json().get("sha", "")[:8]
|
|
117
|
+
logger.info("Boss merge_pr: merged PR #%d for %s by admin %s", pr_number_in, repo_name, user_id)
|
|
118
|
+
return json.dumps({
|
|
119
|
+
"status": "merged",
|
|
120
|
+
"pr": pr_url,
|
|
121
|
+
"pr_number": pr_number_in,
|
|
122
|
+
"title": pr_title,
|
|
123
|
+
"sha": sha,
|
|
124
|
+
"repo": repo_name,
|
|
125
|
+
"note": f"PR #{pr_number_in} merged. Branch \'{branch}\' can now be deleted.",
|
|
126
|
+
})
|
|
127
|
+
if merge_resp.status_code in (405, 409):
|
|
128
|
+
return json.dumps({
|
|
129
|
+
"status": "conflict",
|
|
130
|
+
"pr": pr_url,
|
|
131
|
+
"error": f"PR #{pr_number_in} has merge conflicts — resolve manually on GitHub.",
|
|
132
|
+
})
|
|
133
|
+
return json.dumps({"status": "error", "pr": pr_url,
|
|
134
|
+
"error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
|
|
135
|
+
|
|
136
|
+
# ── Path B: state_store lookup (Sentinel-managed PRs) ─────────────
|
|
137
|
+
open_prs = store.get_open_prs()
|
|
138
|
+
candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
|
|
139
|
+
if fingerprint:
|
|
140
|
+
candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
|
|
141
|
+
if not candidates:
|
|
142
|
+
return json.dumps({"error": f"No open Sentinel PR found for repo \'{repo_name}\'"
|
|
143
|
+
+ (f" with fingerprint \'{fingerprint}\'" if fingerprint else "")})
|
|
144
|
+
fix = candidates[0] # most recent
|
|
145
|
+
pr_url = fix.get("pr_url", "")
|
|
146
|
+
branch = fix.get("branch", "")
|
|
147
|
+
fp = fix.get("fingerprint", "")
|
|
148
|
+
|
|
149
|
+
# Parse owner/repo and PR number from pr_url
|
|
150
|
+
m = _re.search(r"github\\.com/([^/]+/[^/]+)/pull/(\\d+)", pr_url)
|
|
151
|
+
if not m:
|
|
152
|
+
return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
|
|
153
|
+
owner_repo = m.group(1)
|
|
154
|
+
pr_number = m.group(2)'''
|
|
155
|
+
|
|
156
|
+
if OLD_MERGE not in boss:
|
|
157
|
+
print("ERROR: merge_pr old block not found")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
boss = boss.replace(OLD_MERGE, NEW_MERGE, 1)
|
|
161
|
+
|
|
162
|
+
with open(BOSS, 'w', encoding='utf-8') as f:
|
|
163
|
+
f.write(boss)
|
|
164
|
+
print("Fix 1 applied: merge_pr now honours pr_number")
|
|
165
|
+
|
|
166
|
+
# ── Fix 2: slack_bot — allow file_share subtype in thread replies ─────────────
|
|
167
|
+
|
|
168
|
+
SLACK = '/home/sentinel/sentinel/code/sentinel/slack_bot.py'
|
|
169
|
+
|
|
170
|
+
with open(SLACK, 'r', encoding='utf-8') as f:
|
|
171
|
+
slack = f.read()
|
|
172
|
+
|
|
173
|
+
OLD_THREAD = ''' # Thread replies in channels — route to Boss without requiring @mention
|
|
174
|
+
# (so the user can reply "yes" / "go ahead" in a thread without typing @Sentinel)
|
|
175
|
+
if event.get("thread_ts") and not event.get("subtype"):'''
|
|
176
|
+
|
|
177
|
+
NEW_THREAD = ''' # Thread replies in channels — route to Boss without requiring @mention
|
|
178
|
+
# (so the user can reply "yes" / "go ahead" in a thread without typing @Sentinel)
|
|
179
|
+
# Also handle file_share subtype (image/file attached without text)
|
|
180
|
+
_subtype = event.get("subtype", "")
|
|
181
|
+
if event.get("thread_ts") and (not _subtype or _subtype == "file_share"):'''
|
|
182
|
+
|
|
183
|
+
if OLD_THREAD not in slack:
|
|
184
|
+
print("ERROR: slack_bot thread handler not found")
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
slack = slack.replace(OLD_THREAD, NEW_THREAD, 1)
|
|
188
|
+
|
|
189
|
+
# Also ensure empty text with files still gets dispatched (set fallback text)
|
|
190
|
+
OLD_DISPATCH = ''' await _run_turn(session, text, client, cfg_loader, store, attachments=attachments, is_admin=is_admin)'''
|
|
191
|
+
NEW_DISPATCH = ''' # If message has only a file and no text, use a placeholder so Claude processes the attachment
|
|
192
|
+
if not text.strip() and attachments:
|
|
193
|
+
text = "(see attached file)"
|
|
194
|
+
await _run_turn(session, text, client, cfg_loader, store, attachments=attachments, is_admin=is_admin)'''
|
|
195
|
+
|
|
196
|
+
if OLD_DISPATCH not in slack:
|
|
197
|
+
print("ERROR: _run_turn dispatch line not found")
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
slack = slack.replace(OLD_DISPATCH, NEW_DISPATCH, 1)
|
|
201
|
+
|
|
202
|
+
with open(SLACK, 'w', encoding='utf-8') as f:
|
|
203
|
+
f.write(slack)
|
|
204
|
+
print("Fix 2 applied: file_share subtype now accepted in thread replies")
|
|
205
|
+
|
|
206
|
+
# ── Syntax check both files ───────────────────────────────────────────────────
|
|
207
|
+
for path in (BOSS, SLACK):
|
|
208
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
209
|
+
src = f.read()
|
|
210
|
+
try:
|
|
211
|
+
ast.parse(src)
|
|
212
|
+
print(f"Syntax OK: {path.split('/')[-1]}")
|
|
213
|
+
except SyntaxError as e:
|
|
214
|
+
lines = src.splitlines()
|
|
215
|
+
print(f"SyntaxError in {path.split('/')[-1]} at line {e.lineno}: {e.msg}")
|
|
216
|
+
for i in range(max(0, e.lineno-4), min(len(lines), e.lineno+3)):
|
|
217
|
+
print(f" {i+1}: {lines[i]}")
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
print("All done.")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Patch sentinel_boss.py to add chain_release tool."""
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
|
|
6
|
+
|
|
7
|
+
with open(TARGET, 'r', encoding='utf-8') as f:
|
|
8
|
+
content = f.read()
|
|
9
|
+
|
|
10
|
+
# ── 1. Add tool definition (before the closing ] of the tools list) ──────────
|
|
11
|
+
|
|
12
|
+
TOOL_DEF = ''' {
|
|
13
|
+
"name": "chain_release",
|
|
14
|
+
"description": (
|
|
15
|
+
"Execute a sequential release chain: release repo A, update repo B's dependency on A and release B, "
|
|
16
|
+
"update repo C's dependency on B and release C, and so on. "
|
|
17
|
+
"Use when the admin describes a multi-step release pipeline like "
|
|
18
|
+
"'release TypeLib, then update Java-SDK with new TypeLib and release it, then update Admin-SDK...'. "
|
|
19
|
+
"Also handles shorter requests like 'release TypeLib and cascade' by inferring the full chain. "
|
|
20
|
+
"confirmed=false shows the full plan with version numbers; confirmed=true executes all steps in order."
|
|
21
|
+
),
|
|
22
|
+
"input_schema": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"chain": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": {"type": "string"},
|
|
28
|
+
"description": (
|
|
29
|
+
"Ordered list of repo names from first-to-release to last. "
|
|
30
|
+
"E.g. ['Whydah-TypeLib', 'Whydah-Java-SDK', 'Whydah-Admin-SDK', '1881-SSOLoginWebApp']"
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
"confirmed": {
|
|
34
|
+
"type": "boolean",
|
|
35
|
+
"description": "false = show plan only (default); true = execute all steps in sequence",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"required": ["chain", "confirmed"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
]'''
|
|
42
|
+
|
|
43
|
+
OLD_CLOSE = ''' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Workspace helpers'''
|
|
48
|
+
|
|
49
|
+
NEW_CLOSE = TOOL_DEF + '''
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Workspace helpers'''
|
|
53
|
+
|
|
54
|
+
if OLD_CLOSE not in content:
|
|
55
|
+
print("ERROR: tools list closing marker not found")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
content = content.replace(OLD_CLOSE, NEW_CLOSE, 1)
|
|
59
|
+
|
|
60
|
+
# ── 2. Add chain_release to _ADMIN_TOOLS ─────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
OLD_ADMIN = '"manage_release"}'
|
|
63
|
+
NEW_ADMIN = '"manage_release", "chain_release"}'
|
|
64
|
+
if OLD_ADMIN not in content:
|
|
65
|
+
print("ERROR: _ADMIN_TOOLS not found")
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
content = content.replace(OLD_ADMIN, NEW_ADMIN, 1)
|
|
68
|
+
|
|
69
|
+
# ── 3. Add handler (before final "unknown tool" fallback) ────────────────────
|
|
70
|
+
|
|
71
|
+
HANDLER = ''' if name == "chain_release":
|
|
72
|
+
from .dependency_manager import get_artifact_id, get_release_version, update_dependency
|
|
73
|
+
from .git_manager import commit_file_change, push_dep_update
|
|
74
|
+
from .cicd_trigger import _trigger_jenkins_release
|
|
75
|
+
from .notify import slack_alert
|
|
76
|
+
|
|
77
|
+
chain_repos = inputs.get("chain", [])
|
|
78
|
+
confirmed = bool(inputs.get("confirmed", False))
|
|
79
|
+
|
|
80
|
+
if not chain_repos or len(chain_repos) < 2:
|
|
81
|
+
return json.dumps({"error": "chain must contain at least 2 repos"})
|
|
82
|
+
|
|
83
|
+
# Resolve repo configs (fuzzy match)
|
|
84
|
+
resolved = []
|
|
85
|
+
for rname in chain_repos:
|
|
86
|
+
repo = cfg_loader.repos.get(rname)
|
|
87
|
+
if not repo:
|
|
88
|
+
for k, v in cfg_loader.repos.items():
|
|
89
|
+
if rname.lower() in k.lower():
|
|
90
|
+
repo = v
|
|
91
|
+
rname = k
|
|
92
|
+
break
|
|
93
|
+
if not repo:
|
|
94
|
+
return json.dumps({"error": f"Repo not found: {rname}. Known: {list(cfg_loader.repos.keys())}"})
|
|
95
|
+
resolved.append(repo)
|
|
96
|
+
|
|
97
|
+
# Gather artifact IDs and release versions for each repo
|
|
98
|
+
steps = []
|
|
99
|
+
for repo in resolved:
|
|
100
|
+
artifact_id = get_artifact_id(repo.local_path) if repo.local_path else ""
|
|
101
|
+
release_ver = get_release_version(repo.local_path) if repo.local_path else ""
|
|
102
|
+
steps.append({
|
|
103
|
+
"repo": repo.repo_name,
|
|
104
|
+
"artifact_id": artifact_id,
|
|
105
|
+
"release_version": release_ver,
|
|
106
|
+
"cicd_url": repo.cicd_job_url,
|
|
107
|
+
"auto_publish": repo.auto_publish,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
# ── Plan phase ───────────────────────────────────────────────────────
|
|
111
|
+
if not confirmed:
|
|
112
|
+
plan_steps = []
|
|
113
|
+
for i, step in enumerate(steps):
|
|
114
|
+
if i == 0:
|
|
115
|
+
desc = f"Release {step['repo']} v{step['release_version']}"
|
|
116
|
+
else:
|
|
117
|
+
prev = steps[i - 1]
|
|
118
|
+
desc = (
|
|
119
|
+
f"Update {step['repo']} dep on {prev['artifact_id']} "
|
|
120
|
+
f"→ {prev['release_version']}, then release v{step['release_version']}"
|
|
121
|
+
)
|
|
122
|
+
plan_steps.append({
|
|
123
|
+
"step": i + 1,
|
|
124
|
+
"action": desc,
|
|
125
|
+
"repo": step["repo"],
|
|
126
|
+
"release_version": step["release_version"],
|
|
127
|
+
"jenkins_url": step["cicd_url"],
|
|
128
|
+
"mode": "push to main" if step["auto_publish"] else "open PR",
|
|
129
|
+
})
|
|
130
|
+
return json.dumps({
|
|
131
|
+
"plan": f"Sequential release chain: {' → '.join(s['repo'] for s in steps)}",
|
|
132
|
+
"steps": plan_steps,
|
|
133
|
+
"confirm_prompt": "Reply with confirmed=true to execute all steps in sequence.",
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# ── Execute phase ─────────────────────────────────────────────────────
|
|
137
|
+
cfg = cfg_loader.sentinel
|
|
138
|
+
slack_alert(
|
|
139
|
+
cfg.slack_bot_token, cfg.slack_channel,
|
|
140
|
+
f":chains: *Chain release started* ({len(steps)} steps)\\n"
|
|
141
|
+
+ " \\u2192 ".join(s["repo"] for s in steps),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
results = []
|
|
145
|
+
for i, (step, repo) in enumerate(zip(steps, resolved)):
|
|
146
|
+
step_label = f"Step {i + 1}/{len(steps)}"
|
|
147
|
+
|
|
148
|
+
# Update dependency on previous repo's artifact
|
|
149
|
+
if i > 0:
|
|
150
|
+
prev = steps[i - 1]
|
|
151
|
+
if not prev["artifact_id"] or not prev["release_version"]:
|
|
152
|
+
msg = f"{step_label}: skipped — could not read artifact/version from {prev['repo']}"
|
|
153
|
+
results.append({"step": i + 1, "repo": step["repo"], "status": "skipped", "error": msg})
|
|
154
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, f":warning: {msg}")
|
|
155
|
+
break
|
|
156
|
+
changed = update_dependency(repo.local_path, prev["artifact_id"], prev["release_version"])
|
|
157
|
+
if changed:
|
|
158
|
+
commit_msg = (
|
|
159
|
+
f"chore(deps): update {prev['artifact_id']} to {prev['release_version']} [sentinel-chain]\\n\\n"
|
|
160
|
+
f"Part of release chain: {' -> '.join(s['repo'] for s in steps)}"
|
|
161
|
+
)
|
|
162
|
+
status, commit_hash = commit_file_change(repo, ["pom.xml"], commit_msg)
|
|
163
|
+
if status != "committed":
|
|
164
|
+
msg = f"{step_label}: git commit failed for {repo.repo_name}"
|
|
165
|
+
results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
|
|
166
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
167
|
+
f":x: *Chain release failed at {step_label}*\\n{msg}")
|
|
168
|
+
break
|
|
169
|
+
branch, pr_url = push_dep_update(repo, cfg, prev["artifact_id"], prev["release_version"], commit_hash)
|
|
170
|
+
action = f"<{pr_url}|PR opened>" if pr_url else f"pushed to `{branch}`"
|
|
171
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
172
|
+
f":arrow_right: {step_label}: Updated `{repo.repo_name}` "
|
|
173
|
+
f"`{prev['artifact_id']}` \\u2192 `{prev['release_version']}` ({action})")
|
|
174
|
+
else:
|
|
175
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
176
|
+
f":information_source: {step_label}: `{repo.repo_name}` dep already at "
|
|
177
|
+
f"`{prev['release_version']}` — skipping pom.xml update")
|
|
178
|
+
|
|
179
|
+
# Trigger Jenkins release
|
|
180
|
+
if repo.cicd_job_url and repo.cicd_type:
|
|
181
|
+
success = _trigger_jenkins_release(repo)
|
|
182
|
+
if success:
|
|
183
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
184
|
+
f":rocket: {step_label}: Jenkins release triggered — "
|
|
185
|
+
f"`{repo.repo_name}` v{step['release_version']}")
|
|
186
|
+
results.append({"step": i + 1, "repo": step["repo"], "status": "released",
|
|
187
|
+
"version": step["release_version"]})
|
|
188
|
+
else:
|
|
189
|
+
msg = f"Jenkins release trigger failed for {repo.repo_name}"
|
|
190
|
+
results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
|
|
191
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
192
|
+
f":x: *Chain release failed at {step_label}*\\n{msg}")
|
|
193
|
+
break
|
|
194
|
+
else:
|
|
195
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel,
|
|
196
|
+
f":information_source: {step_label}: `{repo.repo_name}` has no CI/CD — "
|
|
197
|
+
f"pom.xml updated but Jenkins not triggered")
|
|
198
|
+
results.append({"step": i + 1, "repo": step["repo"], "status": "no_cicd",
|
|
199
|
+
"note": "pom.xml updated, no Jenkins trigger configured"})
|
|
200
|
+
|
|
201
|
+
all_ok = all(r.get("status") in ("released", "no_cicd") for r in results)
|
|
202
|
+
final = "completed" if all_ok else "partial" if results else "failed"
|
|
203
|
+
slack_alert(
|
|
204
|
+
cfg.slack_bot_token, cfg.slack_channel,
|
|
205
|
+
f":{'white_check_mark' if all_ok else 'warning'}: *Chain release {final}* \\u2014 "
|
|
206
|
+
+ ", ".join(
|
|
207
|
+
f"`{r['repo']}` {'\\u2713' if r.get('status') in ('released', 'no_cicd') else '\\u2717'}"
|
|
208
|
+
for r in results
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
logger.info("Boss chain_release: %s by %s — %s", final, user_id, results)
|
|
212
|
+
return json.dumps({"status": final, "steps": results})
|
|
213
|
+
|
|
214
|
+
'''
|
|
215
|
+
|
|
216
|
+
OLD_FALLBACK = ' return json.dumps({"error": f"unknown tool: {name}"})'
|
|
217
|
+
if OLD_FALLBACK not in content:
|
|
218
|
+
print("ERROR: unknown tool fallback not found")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
content = content.replace(OLD_FALLBACK, HANDLER + OLD_FALLBACK, 1)
|
|
221
|
+
|
|
222
|
+
with open(TARGET, 'w', encoding='utf-8') as f:
|
|
223
|
+
f.write(content)
|
|
224
|
+
|
|
225
|
+
print("Patch applied successfully.")
|
|
226
|
+
|
|
227
|
+
# Verify
|
|
228
|
+
import ast, py_compile, tempfile, os
|
|
229
|
+
with tempfile.NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp:
|
|
230
|
+
tmp.write(content)
|
|
231
|
+
tmp_name = tmp.name
|
|
232
|
+
try:
|
|
233
|
+
py_compile.compile(tmp_name, doraise=True)
|
|
234
|
+
print("Syntax OK")
|
|
235
|
+
finally:
|
|
236
|
+
os.unlink(tmp_name)
|
|
@@ -7,6 +7,7 @@ triggered by the normal GitHub merge webhook.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import re
|
|
10
|
+
import time
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
import requests
|
|
@@ -16,8 +17,12 @@ from .state_store import StateStore
|
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
20
|
+
# How long to wait for a Jenkins release build to complete (chain releases can be slow)
|
|
21
|
+
JENKINS_RELEASE_TIMEOUT = 900 # 15 minutes
|
|
22
|
+
JENKINS_POLL_INTERVAL = 20 # seconds between polls
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
|
|
25
|
+
def trigger(repo: RepoConfig, store: StateStore, fingerprint: str, wait: bool = False) -> bool:
|
|
21
26
|
if not repo.cicd_type or not repo.cicd_job_url:
|
|
22
27
|
return True
|
|
23
28
|
|
|
@@ -25,7 +30,7 @@ def trigger(repo: RepoConfig, store: StateStore, fingerprint: str) -> bool:
|
|
|
25
30
|
if cicd_type == "jenkins":
|
|
26
31
|
return _trigger_jenkins(repo)
|
|
27
32
|
elif cicd_type in ("jenkins_release", "jenkins-release"):
|
|
28
|
-
return _trigger_jenkins_release(repo)
|
|
33
|
+
return _trigger_jenkins_release(repo, wait=wait)
|
|
29
34
|
elif cicd_type in ("github_actions", "github-actions"):
|
|
30
35
|
return _trigger_github_actions(repo, fingerprint)
|
|
31
36
|
else:
|
|
@@ -44,29 +49,133 @@ def _trigger_jenkins(repo: RepoConfig) -> bool:
|
|
|
44
49
|
return success
|
|
45
50
|
|
|
46
51
|
|
|
47
|
-
def
|
|
48
|
-
|
|
52
|
+
def _get_last_build_number(session: requests.Session, job_url: str) -> int:
|
|
53
|
+
"""Return the current lastBuild number for a Jenkins job, or 0 if none."""
|
|
54
|
+
try:
|
|
55
|
+
r = session.get(f"{job_url.rstrip('/')}/api/json", timeout=10)
|
|
56
|
+
data = r.json()
|
|
57
|
+
lb = data.get("lastBuild")
|
|
58
|
+
return lb["number"] if lb else 0
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.warning("Could not get last build number for %s: %s", job_url, exc)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _wait_for_jenkins_build(
|
|
65
|
+
session: requests.Session,
|
|
66
|
+
job_url: str,
|
|
67
|
+
prev_build_num: int,
|
|
68
|
+
timeout: int = JENKINS_RELEASE_TIMEOUT,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
Poll until a new build appears and completes. Returns True on SUCCESS.
|
|
72
|
+
Logs progress every poll cycle.
|
|
73
|
+
"""
|
|
74
|
+
deadline = time.time() + timeout
|
|
75
|
+
build_num = None
|
|
76
|
+
|
|
77
|
+
# Phase 1: wait for a new build to appear
|
|
78
|
+
while time.time() < deadline:
|
|
79
|
+
current = _get_last_build_number(session, job_url)
|
|
80
|
+
if current > prev_build_num:
|
|
81
|
+
build_num = current
|
|
82
|
+
logger.info("Jenkins build #%d started for %s", build_num, job_url)
|
|
83
|
+
break
|
|
84
|
+
logger.debug("Waiting for Jenkins build to start (last=%d)...", current)
|
|
85
|
+
time.sleep(JENKINS_POLL_INTERVAL)
|
|
86
|
+
else:
|
|
87
|
+
logger.error("Timed out waiting for Jenkins build to start: %s", job_url)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
# Phase 2: poll until build completes
|
|
91
|
+
build_api = f"{job_url.rstrip('/')}/{build_num}/api/json"
|
|
92
|
+
while time.time() < deadline:
|
|
93
|
+
try:
|
|
94
|
+
r = session.get(build_api, timeout=10)
|
|
95
|
+
data = r.json()
|
|
96
|
+
if not data.get("building", True):
|
|
97
|
+
result = data.get("result", "UNKNOWN")
|
|
98
|
+
if result == "SUCCESS":
|
|
99
|
+
logger.info("Jenkins build #%d SUCCESS: %s", build_num, job_url)
|
|
100
|
+
return True
|
|
101
|
+
else:
|
|
102
|
+
logger.error("Jenkins build #%d %s: %s", build_num, result, job_url)
|
|
103
|
+
return False
|
|
104
|
+
elapsed = int(time.time() - (deadline - timeout))
|
|
105
|
+
logger.info("Jenkins build #%d still running (%ds elapsed)...", build_num, elapsed)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.warning("Jenkins poll error for %s: %s", build_api, exc)
|
|
108
|
+
time.sleep(JENKINS_POLL_INTERVAL)
|
|
109
|
+
|
|
110
|
+
logger.error("Timed out waiting for Jenkins build #%d to complete: %s", build_num, job_url)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _trigger_jenkins_release(repo: RepoConfig, wait: bool = False) -> bool:
|
|
115
|
+
# Maven Release Plugin — POST to /m2release/submit
|
|
116
|
+
# Jenkins/Stapler requires a `json` parameter containing the form data or it returns 400.
|
|
49
117
|
release_ver, dev_ver = _maven_release_versions(repo.local_path)
|
|
118
|
+
if not release_ver:
|
|
119
|
+
logger.error("Jenkins release: could not determine release version from pom.xml")
|
|
120
|
+
return False
|
|
50
121
|
url = f"{repo.cicd_job_url.rstrip('/')}/m2release/submit"
|
|
51
|
-
|
|
122
|
+
auth = (repo.cicd_user or "sentinel", repo.cicd_token)
|
|
123
|
+
|
|
124
|
+
# Fetch crumb (CSRF token) using a session so the cookie is preserved
|
|
125
|
+
session = requests.Session()
|
|
126
|
+
session.auth = auth
|
|
127
|
+
from urllib.parse import urlparse
|
|
128
|
+
parsed = urlparse(repo.cicd_job_url)
|
|
129
|
+
jenkins_root = f"{parsed.scheme}://{parsed.netloc}"
|
|
130
|
+
crumb_data = session.get(f"{jenkins_root}/crumbIssuer/api/json", timeout=10).json()
|
|
131
|
+
crumb_header = {crumb_data["crumbRequestField"]: crumb_data["crumb"]}
|
|
132
|
+
|
|
133
|
+
# Record current last build number before triggering
|
|
134
|
+
prev_build_num = _get_last_build_number(session, repo.cicd_job_url) if wait else 0
|
|
135
|
+
|
|
136
|
+
scm_tag = f"{repo.repo_name}-{release_ver}"
|
|
137
|
+
import json as _json
|
|
138
|
+
stapler_json = _json.dumps({
|
|
139
|
+
"releaseVersion": release_ver,
|
|
140
|
+
"developmentVersion": dev_ver,
|
|
141
|
+
"isDryRun": False,
|
|
142
|
+
"specifyScmCredentials": False,
|
|
143
|
+
"scmUsername": "",
|
|
144
|
+
"scmCommentPrefix": "[maven-release-plugin] ",
|
|
145
|
+
"appendHudsonUserName": False,
|
|
146
|
+
"specifyScmTag": False,
|
|
147
|
+
"scmTag": scm_tag,
|
|
148
|
+
})
|
|
149
|
+
resp = session.post(
|
|
52
150
|
url,
|
|
53
|
-
|
|
151
|
+
headers={
|
|
152
|
+
**crumb_header,
|
|
153
|
+
"Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
|
|
154
|
+
},
|
|
54
155
|
data={
|
|
55
|
-
"releaseVersion":
|
|
156
|
+
"releaseVersion": release_ver,
|
|
56
157
|
"developmentVersion": dev_ver,
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"specifyCustomScmTag": "false",
|
|
158
|
+
"scmCommentPrefix": "[maven-release-plugin] ",
|
|
159
|
+
"scmTag": scm_tag,
|
|
160
|
+
"json": stapler_json,
|
|
61
161
|
},
|
|
62
162
|
timeout=15,
|
|
163
|
+
allow_redirects=False,
|
|
63
164
|
)
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
logger.info("Jenkins Maven Release triggered: %s → %s (next: %s)", repo.cicd_job_url, release_ver, dev_ver)
|
|
67
|
-
else:
|
|
165
|
+
triggered = resp.status_code in (200, 201, 204, 302)
|
|
166
|
+
if not triggered:
|
|
68
167
|
logger.error("Jenkins release trigger failed (%s): %s", resp.status_code, resp.text[:200])
|
|
69
|
-
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
logger.info(
|
|
171
|
+
"Jenkins Maven Release triggered: %s → %s (next: %s)%s",
|
|
172
|
+
repo.cicd_job_url, release_ver, dev_ver,
|
|
173
|
+
" — waiting for completion..." if wait else "",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if wait:
|
|
177
|
+
return _wait_for_jenkins_build(session, repo.cicd_job_url, prev_build_num)
|
|
178
|
+
return True
|
|
70
179
|
|
|
71
180
|
|
|
72
181
|
def _maven_release_versions(local_path: str) -> tuple[str, str]:
|