@misterhuydo/sentinel 1.6.8 → 1.6.10
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/minify-map.json +0 -6
- package/.cairn/session.json +4 -6
- package/package.json +1 -1
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/fix_engine.py +13 -4
- package/python/sentinel/git_manager.py +1 -1
- package/python/sentinel/main.py +9 -0
- package/python/tests/test_fix_engine_json.py +126 -95
- package/.cairn/views/7802b9_cicd_trigger.py +0 -171
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-25T09:41:31.525Z
|
package/.cairn/minify-map.json
CHANGED
|
@@ -10,11 +10,5 @@
|
|
|
10
10
|
"state": "edit-ready",
|
|
11
11
|
"minifiedAt": 1774252437350.0059,
|
|
12
12
|
"readCount": 1
|
|
13
|
-
},
|
|
14
|
-
"J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py": {
|
|
15
|
-
"tempPath": "J:\\Projects\\Sentinel\\cli\\.cairn\\views\\7802b9_cicd_trigger.py",
|
|
16
|
-
"state": "compressed",
|
|
17
|
-
"minifiedAt": 1774523631399.9514,
|
|
18
|
-
"readCount": 1
|
|
19
13
|
}
|
|
20
14
|
}
|
package/.cairn/session.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-25T09:42:20.720Z",
|
|
3
|
+
"checkpoint_at": "2026-04-25T09:42:20.724Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
|
-
"J:\\Projects\\Sentinel\\cli\\lib\\test.js"
|
|
7
|
-
"J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py"
|
|
6
|
+
"J:\\Projects\\Sentinel\\cli\\lib\\test.js"
|
|
8
7
|
],
|
|
9
8
|
"notes": [
|
|
10
9
|
{
|
|
@@ -186,7 +185,6 @@
|
|
|
186
185
|
],
|
|
187
186
|
"mtime_snapshot": {
|
|
188
187
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js": 1774252515044.4768,
|
|
189
|
-
"J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059
|
|
190
|
-
"J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py": 1774523631399.9514
|
|
188
|
+
"J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059
|
|
191
189
|
}
|
|
192
190
|
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.10"
|
|
@@ -582,6 +582,12 @@ def generate_fix(
|
|
|
582
582
|
if timed_out:
|
|
583
583
|
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
584
584
|
return "error", None, ""
|
|
585
|
+
# Envelope check first: a successful JSON result means the attempt
|
|
586
|
+
# authenticated cleanly, even if the diff body contains substrings
|
|
587
|
+
# like "HttpStatus.UNAUTHORIZED" that would fool the substring scan.
|
|
588
|
+
parsed_attempt = _parse_claude_json(raw_output)
|
|
589
|
+
if not parsed_attempt["is_error"] and parsed_attempt["result"]:
|
|
590
|
+
break
|
|
585
591
|
if not _is_auth_error(raw_output):
|
|
586
592
|
break
|
|
587
593
|
logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
|
|
@@ -605,15 +611,18 @@ def generate_fix(
|
|
|
605
611
|
slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
|
|
606
612
|
return "error", None, ""
|
|
607
613
|
|
|
614
|
+
# Parse the JSON envelope: get session_id, cost, and the actual result text.
|
|
615
|
+
parsed = _parse_claude_json(raw_output)
|
|
616
|
+
|
|
617
|
+
# Pass empty when not is_error — the auth-signal regex would otherwise
|
|
618
|
+
# match trigger words ("authentication", "login", "billing", ...) inside
|
|
619
|
+
# successful diff content.
|
|
608
620
|
alert_if_rate_limited(
|
|
609
621
|
cfg.slack_bot_token,
|
|
610
622
|
cfg.slack_channel,
|
|
611
623
|
source=f"fix_engine/{event.fingerprint}",
|
|
612
|
-
output=raw_output,
|
|
624
|
+
output=raw_output if parsed["is_error"] else "",
|
|
613
625
|
)
|
|
614
|
-
|
|
615
|
-
# Parse the JSON envelope: get session_id, cost, and the actual result text.
|
|
616
|
-
parsed = _parse_claude_json(raw_output)
|
|
617
626
|
if parsed["is_error"] and not parsed["result"]:
|
|
618
627
|
# Fall back to legacy text parsing if JSON decode failed completely.
|
|
619
628
|
# (Older Claude CLI versions may not emit JSON; --output-format flag is ignored.)
|
|
@@ -479,7 +479,7 @@ def _run_tests(repo: RepoConfig, local_path: str) -> bool:
|
|
|
479
479
|
|
|
480
480
|
logger.info("Running tests for %s: %s", repo.repo_name, " ".join(cmd))
|
|
481
481
|
try:
|
|
482
|
-
r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=
|
|
482
|
+
r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=900)
|
|
483
483
|
except FileNotFoundError:
|
|
484
484
|
raise MissingToolError(cmd[0])
|
|
485
485
|
if r.returncode != 0:
|
package/python/sentinel/main.py
CHANGED
|
@@ -606,6 +606,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
606
606
|
mark_done(event.issue_file) # archive so it doesn't re-prompt every poll
|
|
607
607
|
return
|
|
608
608
|
|
|
609
|
+
# Per-project lock — serialise issue processing within a project so two
|
|
610
|
+
# concurrent claude sessions never race on the working tree of any repo.
|
|
611
|
+
async with _project_lock(sentinel.project_name or "_default"):
|
|
612
|
+
return await _handle_issue_locked(event, repo, cfg_loader, store)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
async def _handle_issue_locked(event, repo, cfg_loader, store):
|
|
616
|
+
"""Heavy work portion of _handle_issue — serialised per project via _project_lock."""
|
|
617
|
+
sentinel = cfg_loader.sentinel
|
|
609
618
|
auto_commit = resolve_auto_commit(repo, sentinel)
|
|
610
619
|
auto_release = resolve_auto_release(repo, sentinel)
|
|
611
620
|
|
|
@@ -1,95 +1,126 @@
|
|
|
1
|
-
"""
|
|
2
|
-
test_fix_engine_json.py — Unit tests for _parse_claude_json().
|
|
3
|
-
|
|
4
|
-
Parses the single-object JSON emitted by `claude --print --output-format json`.
|
|
5
|
-
Critical: must extract session_id (for resume), cost (for budget tracking),
|
|
6
|
-
and the result text (which contains the patch).
|
|
7
|
-
"""
|
|
8
|
-
import json
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
from sentinel.fix_engine import _parse_claude_json
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _wrap(result_text: str, session_id: str = "abc-123",
|
|
15
|
-
cost: float = 0.05, is_error: bool = False) -> str:
|
|
16
|
-
return json.dumps({
|
|
17
|
-
"type": "result",
|
|
18
|
-
"subtype": "success" if not is_error else "error",
|
|
19
|
-
"is_error": is_error,
|
|
20
|
-
"result": result_text,
|
|
21
|
-
"session_id": session_id,
|
|
22
|
-
"total_cost_usd": cost,
|
|
23
|
-
"duration_ms": 1234,
|
|
24
|
-
"stop_reason": "end_turn",
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# ── happy path ────────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
def test_extracts_result_session_cost():
|
|
31
|
-
raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
|
|
32
|
-
parsed = _parse_claude_json(raw)
|
|
33
|
-
assert parsed["session_id"] == "sess-1"
|
|
34
|
-
assert parsed["total_cost_usd"] == pytest.approx(0.07)
|
|
35
|
-
assert "patch" in parsed["result"]
|
|
36
|
-
assert parsed["is_error"] is False
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_handles_zero_cost():
|
|
40
|
-
raw = _wrap("ok", "sess", 0.0)
|
|
41
|
-
parsed = _parse_claude_json(raw)
|
|
42
|
-
assert parsed["total_cost_usd"] == 0.0
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_carries_is_error_flag():
|
|
46
|
-
raw = _wrap("err msg", "sess", 0.01, is_error=True)
|
|
47
|
-
parsed = _parse_claude_json(raw)
|
|
48
|
-
assert parsed["is_error"] is True
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# ── tolerant inputs ───────────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
def test_strips_leading_trailing_whitespace():
|
|
54
|
-
raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
|
|
55
|
-
parsed = _parse_claude_json(raw)
|
|
56
|
-
assert parsed["session_id"] == "s"
|
|
57
|
-
assert parsed["result"] == "ok"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_finds_json_after_stderr_garbage():
|
|
61
|
-
"""Claude sometimes prints debug lines before the JSON object."""
|
|
62
|
-
raw = (
|
|
63
|
-
"Some stderr line\n"
|
|
64
|
-
"Another warning\n"
|
|
65
|
-
+ _wrap("payload", "s", 0.0)
|
|
66
|
-
)
|
|
67
|
-
parsed = _parse_claude_json(raw)
|
|
68
|
-
assert parsed["session_id"] == "s"
|
|
69
|
-
assert parsed["result"] == "payload"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_returns_empty_on_unparseable():
|
|
73
|
-
parsed = _parse_claude_json("not json at all")
|
|
74
|
-
assert parsed["session_id"] == ""
|
|
75
|
-
assert parsed["result"] == ""
|
|
76
|
-
assert parsed["total_cost_usd"] == 0.0
|
|
77
|
-
# When unparseable, surface as an error so caller doesn't silently swallow it
|
|
78
|
-
assert parsed["is_error"] is True
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_returns_empty_on_empty_string():
|
|
82
|
-
parsed = _parse_claude_json("")
|
|
83
|
-
assert parsed["session_id"] == ""
|
|
84
|
-
assert parsed["result"] == ""
|
|
85
|
-
assert parsed["is_error"] is True
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_handles_missing_fields_gracefully():
|
|
89
|
-
"""If claude omits some fields, extract what's there and default the rest."""
|
|
90
|
-
raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
|
|
91
|
-
parsed = _parse_claude_json(raw)
|
|
92
|
-
assert parsed["session_id"] == "s"
|
|
93
|
-
assert parsed["result"] == "x"
|
|
94
|
-
assert parsed["total_cost_usd"] == 0.0
|
|
95
|
-
assert parsed["is_error"] is False
|
|
1
|
+
"""
|
|
2
|
+
test_fix_engine_json.py — Unit tests for _parse_claude_json().
|
|
3
|
+
|
|
4
|
+
Parses the single-object JSON emitted by `claude --print --output-format json`.
|
|
5
|
+
Critical: must extract session_id (for resume), cost (for budget tracking),
|
|
6
|
+
and the result text (which contains the patch).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from sentinel.fix_engine import _parse_claude_json, _is_auth_error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _wrap(result_text: str, session_id: str = "abc-123",
|
|
15
|
+
cost: float = 0.05, is_error: bool = False) -> str:
|
|
16
|
+
return json.dumps({
|
|
17
|
+
"type": "result",
|
|
18
|
+
"subtype": "success" if not is_error else "error",
|
|
19
|
+
"is_error": is_error,
|
|
20
|
+
"result": result_text,
|
|
21
|
+
"session_id": session_id,
|
|
22
|
+
"total_cost_usd": cost,
|
|
23
|
+
"duration_ms": 1234,
|
|
24
|
+
"stop_reason": "end_turn",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── happy path ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def test_extracts_result_session_cost():
|
|
31
|
+
raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
|
|
32
|
+
parsed = _parse_claude_json(raw)
|
|
33
|
+
assert parsed["session_id"] == "sess-1"
|
|
34
|
+
assert parsed["total_cost_usd"] == pytest.approx(0.07)
|
|
35
|
+
assert "patch" in parsed["result"]
|
|
36
|
+
assert parsed["is_error"] is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_handles_zero_cost():
|
|
40
|
+
raw = _wrap("ok", "sess", 0.0)
|
|
41
|
+
parsed = _parse_claude_json(raw)
|
|
42
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_carries_is_error_flag():
|
|
46
|
+
raw = _wrap("err msg", "sess", 0.01, is_error=True)
|
|
47
|
+
parsed = _parse_claude_json(raw)
|
|
48
|
+
assert parsed["is_error"] is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── tolerant inputs ───────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def test_strips_leading_trailing_whitespace():
|
|
54
|
+
raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
|
|
55
|
+
parsed = _parse_claude_json(raw)
|
|
56
|
+
assert parsed["session_id"] == "s"
|
|
57
|
+
assert parsed["result"] == "ok"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_finds_json_after_stderr_garbage():
|
|
61
|
+
"""Claude sometimes prints debug lines before the JSON object."""
|
|
62
|
+
raw = (
|
|
63
|
+
"Some stderr line\n"
|
|
64
|
+
"Another warning\n"
|
|
65
|
+
+ _wrap("payload", "s", 0.0)
|
|
66
|
+
)
|
|
67
|
+
parsed = _parse_claude_json(raw)
|
|
68
|
+
assert parsed["session_id"] == "s"
|
|
69
|
+
assert parsed["result"] == "payload"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_returns_empty_on_unparseable():
|
|
73
|
+
parsed = _parse_claude_json("not json at all")
|
|
74
|
+
assert parsed["session_id"] == ""
|
|
75
|
+
assert parsed["result"] == ""
|
|
76
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
77
|
+
# When unparseable, surface as an error so caller doesn't silently swallow it
|
|
78
|
+
assert parsed["is_error"] is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_returns_empty_on_empty_string():
|
|
82
|
+
parsed = _parse_claude_json("")
|
|
83
|
+
assert parsed["session_id"] == ""
|
|
84
|
+
assert parsed["result"] == ""
|
|
85
|
+
assert parsed["is_error"] is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_handles_missing_fields_gracefully():
|
|
89
|
+
"""If claude omits some fields, extract what's there and default the rest."""
|
|
90
|
+
raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
|
|
91
|
+
parsed = _parse_claude_json(raw)
|
|
92
|
+
assert parsed["session_id"] == "s"
|
|
93
|
+
assert parsed["result"] == "x"
|
|
94
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
95
|
+
assert parsed["is_error"] is False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_successful_envelope_overrides_unauthorized_substring():
|
|
99
|
+
# Regression: fp 6cb7a875 on 2026-04-27 - Claude generated a valid patch
|
|
100
|
+
# whose unchanged-context lines contained "HttpStatus.UNAUTHORIZED",
|
|
101
|
+
# tripping _is_auth_error and triggering a false "Both API key and OAuth
|
|
102
|
+
# failed" alert. The fix is to consult the JSON envelope first.
|
|
103
|
+
diff_with_unauthorized = (
|
|
104
|
+
"diff --git a/Foo.java b/Foo.java\n"
|
|
105
|
+
"@@ -10,3 +10,3 @@\n"
|
|
106
|
+
" return new ResponseEntity<>(err, HttpStatus.UNAUTHORIZED);\n"
|
|
107
|
+
)
|
|
108
|
+
raw = _wrap(diff_with_unauthorized, is_error=False)
|
|
109
|
+
assert _is_auth_error(raw) is True # substring match alone is fooled
|
|
110
|
+
parsed = _parse_claude_json(raw)
|
|
111
|
+
assert parsed["is_error"] is False # envelope is the source of truth
|
|
112
|
+
assert parsed["result"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize("hint", [
|
|
116
|
+
"Error: Not logged in. Please run claude login.",
|
|
117
|
+
"API key is not set",
|
|
118
|
+
"401 Unauthorized",
|
|
119
|
+
"authentication failed",
|
|
120
|
+
])
|
|
121
|
+
def test_is_auth_error_matches_real_hints(hint):
|
|
122
|
+
assert _is_auth_error(hint) is True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_is_auth_error_skips_benign_text():
|
|
126
|
+
assert _is_auth_error("All good, here is the patch") is False
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import re
|
|
3
|
-
import time
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
import requests
|
|
6
|
-
from .config_loader import RepoConfig
|
|
7
|
-
from .state_store import StateStore
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
|
-
JENKINS_RELEASE_TIMEOUT = 900
|
|
10
|
-
JENKINS_POLL_INTERVAL = 20
|
|
11
|
-
def trigger(repo: RepoConfig, store: StateStore, fingerprint: str, wait: bool = False) -> bool:
|
|
12
|
-
if not repo.cicd_type or not repo.cicd_job_url:
|
|
13
|
-
return True
|
|
14
|
-
cicd_type = repo.cicd_type.lower()
|
|
15
|
-
if cicd_type == "jenkins":
|
|
16
|
-
return _trigger_jenkins(repo)
|
|
17
|
-
elif cicd_type in ("jenkins_release", "jenkins-release"):
|
|
18
|
-
return _trigger_jenkins_release(repo, wait=wait)
|
|
19
|
-
elif cicd_type in ("github_actions", "github-actions"):
|
|
20
|
-
return _trigger_github_actions(repo, fingerprint)
|
|
21
|
-
else:
|
|
22
|
-
logger.warning("Unknown CICD_TYPE '%s' for %s", repo.cicd_type, repo.repo_name)
|
|
23
|
-
return False
|
|
24
|
-
def _trigger_jenkins(repo: RepoConfig) -> bool:
|
|
25
|
-
url = f"{repo.cicd_job_url.rstrip('/')}/build"
|
|
26
|
-
resp = requests.post(url, auth=("sentinel", repo.cicd_token), timeout=15)
|
|
27
|
-
success = resp.status_code in (200, 201, 204)
|
|
28
|
-
if success:
|
|
29
|
-
logger.info("Jenkins build triggered: %s", repo.cicd_job_url)
|
|
30
|
-
else:
|
|
31
|
-
logger.error("Jenkins trigger failed (%s): %s", resp.status_code, resp.text[:200])
|
|
32
|
-
return success
|
|
33
|
-
def _get_last_build_number(session: requests.Session, job_url: str) -> int:
|
|
34
|
-
try:
|
|
35
|
-
r = session.get(f"{job_url.rstrip('/')}/api/json", timeout=10)
|
|
36
|
-
data = r.json()
|
|
37
|
-
lb = data.get("lastBuild")
|
|
38
|
-
return lb["number"] if lb else 0
|
|
39
|
-
except Exception as exc:
|
|
40
|
-
logger.warning("Could not get last build number for %s: %s", job_url, exc)
|
|
41
|
-
return 0
|
|
42
|
-
def _wait_for_jenkins_build(
|
|
43
|
-
session: requests.Session,
|
|
44
|
-
job_url: str,
|
|
45
|
-
prev_build_num: int,
|
|
46
|
-
timeout: int = JENKINS_RELEASE_TIMEOUT,
|
|
47
|
-
) -> bool:
|
|
48
|
-
deadline = time.time() + timeout
|
|
49
|
-
build_num = None
|
|
50
|
-
while time.time() < deadline:
|
|
51
|
-
current = _get_last_build_number(session, job_url)
|
|
52
|
-
if current > prev_build_num:
|
|
53
|
-
build_num = current
|
|
54
|
-
logger.info("Jenkins build
|
|
55
|
-
break
|
|
56
|
-
logger.debug("Waiting for Jenkins build to start (last=%d)...", current)
|
|
57
|
-
time.sleep(JENKINS_POLL_INTERVAL)
|
|
58
|
-
else:
|
|
59
|
-
logger.error("Timed out waiting for Jenkins build to start: %s", job_url)
|
|
60
|
-
return False
|
|
61
|
-
build_api = f"{job_url.rstrip('/')}/{build_num}/api/json"
|
|
62
|
-
while time.time() < deadline:
|
|
63
|
-
try:
|
|
64
|
-
r = session.get(build_api, timeout=10)
|
|
65
|
-
data = r.json()
|
|
66
|
-
if not data.get("building", True):
|
|
67
|
-
result = data.get("result", "UNKNOWN")
|
|
68
|
-
if result == "SUCCESS":
|
|
69
|
-
logger.info("Jenkins build
|
|
70
|
-
return True
|
|
71
|
-
else:
|
|
72
|
-
logger.error("Jenkins build
|
|
73
|
-
return False
|
|
74
|
-
elapsed = int(time.time() - (deadline - timeout))
|
|
75
|
-
logger.info("Jenkins build
|
|
76
|
-
except Exception as exc:
|
|
77
|
-
logger.warning("Jenkins poll error for %s: %s", build_api, exc)
|
|
78
|
-
time.sleep(JENKINS_POLL_INTERVAL)
|
|
79
|
-
logger.error("Timed out waiting for Jenkins build
|
|
80
|
-
return False
|
|
81
|
-
def _trigger_jenkins_release(repo: RepoConfig, wait: bool = False) -> bool:
|
|
82
|
-
release_ver, dev_ver = _maven_release_versions(repo.local_path)
|
|
83
|
-
if not release_ver:
|
|
84
|
-
logger.error("Jenkins release: could not determine release version from pom.xml")
|
|
85
|
-
return False
|
|
86
|
-
url = f"{repo.cicd_job_url.rstrip('/')}/m2release/submit"
|
|
87
|
-
auth = (repo.cicd_user or "sentinel", repo.cicd_token)
|
|
88
|
-
session = requests.Session()
|
|
89
|
-
session.auth = auth
|
|
90
|
-
from urllib.parse import urlparse
|
|
91
|
-
parsed = urlparse(repo.cicd_job_url)
|
|
92
|
-
jenkins_root = f"{parsed.scheme}://{parsed.netloc}"
|
|
93
|
-
crumb_data = session.get(f"{jenkins_root}/crumbIssuer/api/json", timeout=10).json()
|
|
94
|
-
crumb_header = {crumb_data["crumbRequestField"]: crumb_data["crumb"]}
|
|
95
|
-
prev_build_num = _get_last_build_number(session, repo.cicd_job_url) if wait else 0
|
|
96
|
-
scm_tag = f"{repo.repo_name}-{release_ver}"
|
|
97
|
-
import json as _json
|
|
98
|
-
stapler_json = _json.dumps({
|
|
99
|
-
"releaseVersion": release_ver,
|
|
100
|
-
"developmentVersion": dev_ver,
|
|
101
|
-
"isDryRun": False,
|
|
102
|
-
"specifyScmCredentials": False,
|
|
103
|
-
"scmUsername": "",
|
|
104
|
-
"scmCommentPrefix": "[maven-release-plugin] ",
|
|
105
|
-
"appendHudsonUserName": False,
|
|
106
|
-
"specifyScmTag": False,
|
|
107
|
-
"scmTag": scm_tag,
|
|
108
|
-
})
|
|
109
|
-
resp = session.post(
|
|
110
|
-
url,
|
|
111
|
-
headers={
|
|
112
|
-
**crumb_header,
|
|
113
|
-
"Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
|
|
114
|
-
},
|
|
115
|
-
data={
|
|
116
|
-
"releaseVersion": release_ver,
|
|
117
|
-
"developmentVersion": dev_ver,
|
|
118
|
-
"scmCommentPrefix": "[maven-release-plugin] ",
|
|
119
|
-
"scmTag": scm_tag,
|
|
120
|
-
"json": stapler_json,
|
|
121
|
-
},
|
|
122
|
-
timeout=15,
|
|
123
|
-
allow_redirects=False,
|
|
124
|
-
)
|
|
125
|
-
triggered = resp.status_code in (200, 201, 204, 302)
|
|
126
|
-
if not triggered:
|
|
127
|
-
logger.error("Jenkins release trigger failed (%s): %s", resp.status_code, resp.text[:200])
|
|
128
|
-
return False
|
|
129
|
-
logger.info(
|
|
130
|
-
"Jenkins Maven Release triggered: %s → %s (next: %s)%s",
|
|
131
|
-
repo.cicd_job_url, release_ver, dev_ver,
|
|
132
|
-
" — waiting for completion..." if wait else "",
|
|
133
|
-
)
|
|
134
|
-
if wait:
|
|
135
|
-
return _wait_for_jenkins_build(session, repo.cicd_job_url, prev_build_num)
|
|
136
|
-
return True
|
|
137
|
-
def _maven_release_versions(local_path: str) -> tuple[str, str]:
|
|
138
|
-
pom = Path(local_path) / "pom.xml"
|
|
139
|
-
if not pom.exists():
|
|
140
|
-
return ("", "")
|
|
141
|
-
text = pom.read_text(encoding="utf-8", errors="ignore")
|
|
142
|
-
m = re.search(r"<version>(\d+\.\d+\.\d+)-SNAPSHOT</version>", text)
|
|
143
|
-
if not m:
|
|
144
|
-
return ("", "")
|
|
145
|
-
parts = m.group(1).split(".")
|
|
146
|
-
release_ver = m.group(1)
|
|
147
|
-
next_patch = int(parts[2]) + 1
|
|
148
|
-
dev_ver = f"{parts[0]}.{parts[1]}.{next_patch}-SNAPSHOT"
|
|
149
|
-
return release_ver, dev_ver
|
|
150
|
-
def _trigger_github_actions(repo: RepoConfig, fingerprint: str) -> bool:
|
|
151
|
-
owner_repo = _owner_repo(repo.repo_url)
|
|
152
|
-
url = f"https://api.github.com/repos/{owner_repo}/dispatches"
|
|
153
|
-
resp = requests.post(
|
|
154
|
-
url,
|
|
155
|
-
json={"event_type": "sentinel-deploy", "client_payload": {"fingerprint": fingerprint}},
|
|
156
|
-
headers={
|
|
157
|
-
"Authorization": f"Bearer {repo.cicd_token}",
|
|
158
|
-
"Accept": "application/vnd.github+json",
|
|
159
|
-
},
|
|
160
|
-
timeout=15,
|
|
161
|
-
)
|
|
162
|
-
success = resp.status_code == 204
|
|
163
|
-
if success:
|
|
164
|
-
logger.info("GitHub Actions dispatch sent for %s", owner_repo)
|
|
165
|
-
else:
|
|
166
|
-
logger.error("GH Actions dispatch failed (%s): %s", resp.status_code, resp.text[:200])
|
|
167
|
-
return success
|
|
168
|
-
def _owner_repo(repo_url: str) -> str:
|
|
169
|
-
if repo_url.startswith("git@"):
|
|
170
|
-
return repo_url.split(":")[-1].removesuffix(".git")
|
|
171
|
-
return "/".join(repo_url.rstrip("/").split("/")[-2:]).removesuffix(".git")
|