@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.
@@ -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
- def trigger(repo: RepoConfig, store: StateStore, fingerprint: str) -> bool:
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 _trigger_jenkins_release(repo: RepoConfig) -> bool:
48
- # Maven Release Plugin POST to /m2release/submit with release + dev versions
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
- resp = requests.post(
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
- auth=(repo.cicd_user or "sentinel", repo.cicd_token),
151
+ headers={
152
+ **crumb_header,
153
+ "Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
154
+ },
54
155
  data={
55
- "releaseVersion": release_ver,
156
+ "releaseVersion": release_ver,
56
157
  "developmentVersion": dev_ver,
57
- "isDryRun": "false",
58
- "specifyScmCredentials": "false",
59
- "specifyCustomScmCommentPrefix": "false",
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
- success = resp.status_code in (200, 201, 204)
65
- if success:
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
- return success
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]: