@misterhuydo/sentinel 1.5.4 → 1.5.6
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 +13 -1
- package/.cairn/session.json +2 -2
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
- package/.cairn/views/5f5141_main.py +1067 -0
- package/.cairn/views/62a614_bundle.js +4 -1
- package/.cairn/views/7802b9_cicd_trigger.py +171 -0
- package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
- package/lib/.cairn/minify-map.json +6 -0
- package/lib/.cairn/views/2a85cc_init.js +380 -0
- package/lib/.cairn/views/e26996_slack-setup.js +97 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
- package/lib/.cairn/views/fc4a1a_add.js +164 -51
- package/lib/init.js +54 -0
- package/lib/maven.js +212 -0
- package/lib/slack-setup.js +5 -0
- package/package.json +1 -1
- package/python/requirements.txt +1 -0
- package/python/sentinel/.cairn/.cairn-project +0 -0
- package/python/sentinel/.cairn/.hint-lock +1 -0
- package/python/sentinel/.cairn/session.json +9 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +29 -10
- package/python/sentinel/dependency_manager.py +9 -2
- package/python/sentinel/git_manager.py +23 -0
- package/python/sentinel/issue_watcher.py +7 -1
- package/python/sentinel/main.py +353 -8
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +49 -7
- package/python/sentinel/sentinel_boss.py +117 -3
- package/python/sentinel/slack_bot.py +15 -2
- package/python/sentinel/state_store.py +0 -1
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_config_loader.py +138 -0
- package/python/tests/test_log_parser.py +62 -0
- package/python/tests/test_repo_router.py +73 -0
- package/python/tests/test_smoke.py +96 -0
- package/python/tests/test_state_store.py +128 -0
|
@@ -11,7 +11,10 @@ const copies = [
|
|
|
11
11
|
];
|
|
12
12
|
console.log('Bundling Python source into cli/python/...');
|
|
13
13
|
for (const { src, dst } of copies) {
|
|
14
|
-
fs.copySync(src, dst, {
|
|
14
|
+
fs.copySync(src, dst, {
|
|
15
|
+
overwrite: true,
|
|
16
|
+
filter: (src) => !src.includes('__pycache__'),
|
|
17
|
+
});
|
|
15
18
|
console.log(' ' + path.relative(CLI_DIR, src) + ' -> ' + path.relative(CLI_DIR, dst));
|
|
16
19
|
}
|
|
17
20
|
console.log('Done.');
|
|
@@ -0,0 +1,171 @@
|
|
|
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")
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .config_loader import RepoConfig, SentinelConfig
|
|
11
|
+
from .fix_engine import _claude_cmd, _run_claude_attempt, _is_auth_error, _progress_from_line
|
|
12
|
+
from .git_manager import _git_env
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
_META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "NOTIFY:")
|
|
15
|
+
_TASK_TIMEOUT = 900
|
|
16
|
+
@dataclass
|
|
17
|
+
class RepoTask:
|
|
18
|
+
task_file: Path
|
|
19
|
+
repo_name: str
|
|
20
|
+
task_type: str
|
|
21
|
+
description: str
|
|
22
|
+
message: str
|
|
23
|
+
submitter_user_id: str = ""
|
|
24
|
+
notify_user_ids: list = field(default_factory=list)
|
|
25
|
+
fingerprint: str = ""
|
|
26
|
+
timestamp: str = ""
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if not self.fingerprint:
|
|
29
|
+
raw = f"repo-task:{self.repo_name}:{self.task_type}:{self.message[:200]}"
|
|
30
|
+
self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
|
|
31
|
+
if not self.timestamp:
|
|
32
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
33
|
+
def _build_repo_prompt(task: RepoTask, repo: RepoConfig) -> str:
|
|
34
|
+
submitted = f"Requested by: <@{task.submitter_user_id}>" if task.submitter_user_id else ""
|
|
35
|
+
return (
|
|
36
|
+
f"You are implementing a requested change in the repository at {repo.local_path}.\n"
|
|
37
|
+
f"Repository: {repo.repo_name}\n"
|
|
38
|
+
f"{submitted}\n"
|
|
39
|
+
f"\n"
|
|
40
|
+
f"TASK TYPE: {task.task_type}\n"
|
|
41
|
+
f"TASK:\n{task.description}\n\n"
|
|
42
|
+
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
43
|
+
f"INSTRUCTIONS\n"
|
|
44
|
+
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
45
|
+
f"1. Explore the codebase first — understand structure, patterns, and conventions\n"
|
|
46
|
+
f" before making any changes.\n"
|
|
47
|
+
f"2. Implement the requested change following the repo's existing code style.\n"
|
|
48
|
+
f"3. Syntax/compile check modified files.\n"
|
|
49
|
+
f"4. Run tests if available (Maven, Gradle, npm test) — commit only if passing.\n"
|
|
50
|
+
f"5. Commit all changes:\n"
|
|
51
|
+
f" git add -A\n"
|
|
52
|
+
f" git commit -m \"{task.task_type}(<scope>): <concise summary> [sentinel-task]\"\n"
|
|
53
|
+
f"6. End with a brief summary (max 10 lines) of what changed and why.\n"
|
|
54
|
+
f"\n"
|
|
55
|
+
f"BOUNDARIES:\n"
|
|
56
|
+
f"- Never modify CI/CD config files (.github/, Jenkinsfile, pom.xml build sections)\n"
|
|
57
|
+
f"- Implement exactly what was requested — no extra scope\n"
|
|
58
|
+
f"- If the task requires information you don't have:\n"
|
|
59
|
+
f" output exactly: NEEDS_HUMAN: <what is missing>\n"
|
|
60
|
+
f"- If implementing this requires changing Sentinel itself (not this repo):\n"
|
|
61
|
+
f" output exactly: BOSS_ESCALATE: <description>\n"
|
|
62
|
+
)
|
|
63
|
+
def _get_head(local_path: str, env: dict) -> str:
|
|
64
|
+
r = subprocess.run(
|
|
65
|
+
["git", "rev-parse", "HEAD"],
|
|
66
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=10,
|
|
67
|
+
)
|
|
68
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
69
|
+
def _open_task_pr(repo: RepoConfig, cfg: SentinelConfig, branch: str, task: RepoTask, commit_hash: str) -> str:
|
|
70
|
+
if not cfg.github_token:
|
|
71
|
+
logger.warning("GITHUB_TOKEN not set — cannot open PR for repo task")
|
|
72
|
+
return ""
|
|
73
|
+
url = repo.repo_url
|
|
74
|
+
owner_repo = (
|
|
75
|
+
url.split(":")[-1].removesuffix(".git") if url.startswith("git@")
|
|
76
|
+
else "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
|
|
77
|
+
)
|
|
78
|
+
title = f"[Sentinel] {task.task_type}({repo.repo_name}): {task.message[:60]}"
|
|
79
|
+
submitter_line = f"**Requested by:** <@{task.submitter_user_id}>\n" if task.submitter_user_id else ""
|
|
80
|
+
body = (
|
|
81
|
+
f"
|
|
82
|
+
f"**Task type:** `{task.task_type}`\n"
|
|
83
|
+
f"{submitter_line}"
|
|
84
|
+
f"**Commit:** `{commit_hash[:8]}`\n\n"
|
|
85
|
+
f"
|
|
86
|
+
f"---\n_Implemented by Sentinel. Review and merge when satisfied._"
|
|
87
|
+
)
|
|
88
|
+
import requests as _req
|
|
89
|
+
resp = _req.post(
|
|
90
|
+
f"https://api.github.com/repos/{owner_repo}/pulls",
|
|
91
|
+
json={"title": title, "body": body, "head": branch, "base": repo.branch},
|
|
92
|
+
headers={"Authorization": f"Bearer {cfg.github_token}", "Accept": "application/vnd.github+json"},
|
|
93
|
+
timeout=30,
|
|
94
|
+
)
|
|
95
|
+
if resp.status_code == 201:
|
|
96
|
+
pr_url = resp.json().get("html_url", "")
|
|
97
|
+
logger.info("Task PR opened: %s", pr_url)
|
|
98
|
+
return pr_url
|
|
99
|
+
logger.error("Failed to open task PR (%s): %s", resp.status_code, resp.text[:300])
|
|
100
|
+
return ""
|
|
101
|
+
def run_repo_task(
|
|
102
|
+
task: RepoTask,
|
|
103
|
+
repo: RepoConfig,
|
|
104
|
+
cfg: SentinelConfig,
|
|
105
|
+
store=None,
|
|
106
|
+
on_progress=None,
|
|
107
|
+
) -> tuple[str, str | None]:
|
|
108
|
+
local_path = repo.local_path
|
|
109
|
+
if not local_path or not Path(local_path).exists():
|
|
110
|
+
return "error", f"Local clone not found at {local_path!r} — run `sentinel init` first"
|
|
111
|
+
env = _git_env(repo)
|
|
112
|
+
r = subprocess.run(
|
|
113
|
+
["git", "pull", "--rebase", "origin", repo.branch],
|
|
114
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
115
|
+
)
|
|
116
|
+
if r.returncode != 0:
|
|
117
|
+
logger.warning("git pull failed before repo task for %s: %s", repo.repo_name, r.stderr[:200])
|
|
118
|
+
before_hash = _get_head(local_path, env)
|
|
119
|
+
prompt = _build_repo_prompt(task, repo)
|
|
120
|
+
claude_log = Path(local_path).parent / "logs" / f"repo-task-{task.fingerprint[:8]}.log"
|
|
121
|
+
claude_log.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
if on_progress:
|
|
123
|
+
on_progress(f":mag: Exploring `{repo.repo_name}`...")
|
|
124
|
+
import os as _os
|
|
125
|
+
skip_perms = _os.getuid() != 0
|
|
126
|
+
oauth_cmd = (
|
|
127
|
+
[cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]
|
|
128
|
+
if skip_perms else
|
|
129
|
+
[cfg.claude_code_bin, "--print", prompt]
|
|
130
|
+
)
|
|
131
|
+
api_cmd = (
|
|
132
|
+
[cfg.claude_code_bin, "--dangerously-skip-permissions", "--bare", "--print", prompt]
|
|
133
|
+
if skip_perms else
|
|
134
|
+
[cfg.claude_code_bin, "--bare", "--print", prompt]
|
|
135
|
+
)
|
|
136
|
+
api_env = {**env, "ANTHROPIC_API_KEY": cfg.anthropic_api_key} if cfg.anthropic_api_key else None
|
|
137
|
+
try:
|
|
138
|
+
output, timed_out = _run_claude_attempt(
|
|
139
|
+
cfg.claude_code_bin, prompt, env,
|
|
140
|
+
cwd=local_path, claude_log_path=claude_log, on_progress=on_progress,
|
|
141
|
+
cmd_override=oauth_cmd,
|
|
142
|
+
)
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
return "error", f"Claude binary not found: {cfg.claude_code_bin}"
|
|
145
|
+
if timed_out:
|
|
146
|
+
return "error", "Claude timed out after 15 minutes."
|
|
147
|
+
stripped = output.strip()
|
|
148
|
+
if _is_auth_error(stripped):
|
|
149
|
+
if api_env:
|
|
150
|
+
logger.warning("repo_task/%s: OAuth session issue — retrying with API key", repo.repo_name)
|
|
151
|
+
if on_progress:
|
|
152
|
+
on_progress(":key: Auth refreshed — retrying...")
|
|
153
|
+
try:
|
|
154
|
+
output, timed_out = _run_claude_attempt(
|
|
155
|
+
cfg.claude_code_bin, prompt, api_env,
|
|
156
|
+
cwd=local_path, claude_log_path=claude_log, on_progress=on_progress,
|
|
157
|
+
cmd_override=api_cmd,
|
|
158
|
+
)
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
return "error", f"Claude binary not found: {cfg.claude_code_bin}"
|
|
161
|
+
if timed_out:
|
|
162
|
+
return "error", "Claude timed out after 15 minutes."
|
|
163
|
+
stripped = output.strip()
|
|
164
|
+
if _is_auth_error(stripped):
|
|
165
|
+
return "error", "Claude authentication failed — check ANTHROPIC_API_KEY."
|
|
166
|
+
else:
|
|
167
|
+
return "error", "Claude authentication error — run `claude login` on the server or set ANTHROPIC_API_KEY."
|
|
168
|
+
if stripped.upper().startswith("SKIP:"):
|
|
169
|
+
reason = stripped[5:].strip()
|
|
170
|
+
logger.info("Repo task skipped for %s: %s", repo.repo_name, reason[:200])
|
|
171
|
+
return "skip", reason
|
|
172
|
+
if stripped.upper().startswith("NEEDS_HUMAN:"):
|
|
173
|
+
reason = stripped[12:].strip()
|
|
174
|
+
logger.info("Repo task needs human for %s: %s", repo.repo_name, reason[:200])
|
|
175
|
+
return "needs_human", reason
|
|
176
|
+
if stripped.upper().startswith("BOSS_ESCALATE:"):
|
|
177
|
+
description = stripped[14:].strip()
|
|
178
|
+
try:
|
|
179
|
+
from .sentinel_dev import drop_escalation
|
|
180
|
+
drop_escalation(
|
|
181
|
+
Path(local_path).parent,
|
|
182
|
+
description,
|
|
183
|
+
source="repo_task/BOSS_ESCALATE",
|
|
184
|
+
source_fingerprint=task.fingerprint,
|
|
185
|
+
)
|
|
186
|
+
logger.info("Repo task escalated to Patch for %s: %s", repo.repo_name, description[:200])
|
|
187
|
+
except Exception as _e:
|
|
188
|
+
logger.error("Failed to drop BOSS_ESCALATE from repo task: %s", _e)
|
|
189
|
+
return "skip", f"Escalated to Patch: {description[:200]}"
|
|
190
|
+
after_hash = _get_head(local_path, env)
|
|
191
|
+
if after_hash == before_hash:
|
|
192
|
+
diff_r = subprocess.run(
|
|
193
|
+
["git", "status", "--porcelain"],
|
|
194
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=10,
|
|
195
|
+
)
|
|
196
|
+
if diff_r.stdout.strip():
|
|
197
|
+
commit_msg = f"{task.task_type}(sentinel-task): {task.message[:60]} [sentinel-task]"
|
|
198
|
+
subprocess.run(["git", "add", "-A"], cwd=local_path, env=env, timeout=30, capture_output=True)
|
|
199
|
+
cr = subprocess.run(
|
|
200
|
+
["git", "commit", "-m", commit_msg],
|
|
201
|
+
cwd=local_path, env=env, timeout=30, capture_output=True, text=True,
|
|
202
|
+
)
|
|
203
|
+
if cr.returncode != 0:
|
|
204
|
+
logger.error("Auto-commit failed for %s: %s", repo.repo_name, cr.stderr[:200])
|
|
205
|
+
subprocess.run(["git", "checkout", "."], cwd=local_path, env=env, timeout=30, capture_output=True)
|
|
206
|
+
return "error", "Claude made changes but commit failed."
|
|
207
|
+
after_hash = _get_head(local_path, env)
|
|
208
|
+
else:
|
|
209
|
+
logger.warning("Repo task: Claude ran but made no changes for %s", repo.repo_name)
|
|
210
|
+
return "skip", "Claude completed but made no changes to the codebase."
|
|
211
|
+
commit_hash = after_hash
|
|
212
|
+
if on_progress:
|
|
213
|
+
on_progress(f":arrow_up: Pushing to `{repo.repo_name}`...")
|
|
214
|
+
if repo.auto_publish:
|
|
215
|
+
r = subprocess.run(
|
|
216
|
+
["git", "push", "origin", repo.branch],
|
|
217
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
218
|
+
)
|
|
219
|
+
if r.returncode != 0:
|
|
220
|
+
logger.error("git push failed for %s: %s", repo.repo_name, r.stderr[:300])
|
|
221
|
+
return "error", f"git push failed: {r.stderr.strip()[:200]}"
|
|
222
|
+
logger.info("Repo task: pushed to %s/%s sha=%s", repo.repo_name, repo.branch, commit_hash[:8])
|
|
223
|
+
if repo.cicd_type:
|
|
224
|
+
try:
|
|
225
|
+
from .cicd_trigger import trigger as cicd_trigger
|
|
226
|
+
ok = cicd_trigger(repo, None, task.fingerprint)
|
|
227
|
+
if ok:
|
|
228
|
+
logger.info("Repo task: CI/CD triggered for %s (%s)", repo.repo_name, repo.cicd_type)
|
|
229
|
+
if on_progress:
|
|
230
|
+
on_progress(f":rocket: Release triggered via `{repo.cicd_type}`")
|
|
231
|
+
return "done", f"__cicd__{repo.cicd_type}"
|
|
232
|
+
else:
|
|
233
|
+
logger.warning("Repo task: CI/CD trigger failed for %s", repo.repo_name)
|
|
234
|
+
if on_progress:
|
|
235
|
+
on_progress(f":warning: CI/CD trigger failed for `{repo.cicd_type}` — check logs")
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.warning("Repo task: CI/CD trigger failed for %s: %s", repo.repo_name, exc)
|
|
238
|
+
if on_progress:
|
|
239
|
+
on_progress(f":warning: CI/CD trigger error — {exc}")
|
|
240
|
+
return "done", None
|
|
241
|
+
else:
|
|
242
|
+
branch = f"sentinel/task-{task.fingerprint[:8]}"
|
|
243
|
+
subprocess.run(["git", "checkout", "-B", branch], cwd=local_path, env=env,
|
|
244
|
+
timeout=30, capture_output=True)
|
|
245
|
+
r = subprocess.run(
|
|
246
|
+
["git", "push", "-u", "origin", branch],
|
|
247
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
248
|
+
)
|
|
249
|
+
if r.returncode != 0:
|
|
250
|
+
logger.error("git push branch failed for %s: %s", repo.repo_name, r.stderr[:300])
|
|
251
|
+
subprocess.run(["git", "checkout", repo.branch], cwd=local_path, env=env,
|
|
252
|
+
timeout=30, capture_output=True)
|
|
253
|
+
return "error", f"git push failed: {r.stderr.strip()[:200]}"
|
|
254
|
+
subprocess.run(["git", "checkout", repo.branch], cwd=local_path, env=env,
|
|
255
|
+
timeout=30, capture_output=True)
|
|
256
|
+
pr_url = _open_task_pr(repo, cfg, branch, task, commit_hash)
|
|
257
|
+
logger.info("Repo task: PR opened for %s: %s", repo.repo_name, pr_url)
|
|
258
|
+
return "done", pr_url or None
|
|
259
|
+
def drop_repo_task(
|
|
260
|
+
project_dir: Path,
|
|
261
|
+
repo_name: str,
|
|
262
|
+
task_type: str,
|
|
263
|
+
description: str,
|
|
264
|
+
submitter_user_id: str = "",
|
|
265
|
+
notify_user_ids: list | None = None,
|
|
266
|
+
) -> Path:
|
|
267
|
+
tasks_dir = project_dir / "repo-tasks"
|
|
268
|
+
tasks_dir.mkdir(exist_ok=True)
|
|
269
|
+
import uuid as _uuid
|
|
270
|
+
ts = int(time.time())
|
|
271
|
+
fname = f"{repo_name}-{_uuid.uuid4().hex[:8]}-{ts}.txt"
|
|
272
|
+
fpath = tasks_dir / fname
|
|
273
|
+
lines = [
|
|
274
|
+
f"REPO: {repo_name}",
|
|
275
|
+
f"TYPE: {task_type}",
|
|
276
|
+
(f"SUBMITTED_BY: <@{submitter_user_id}> ({submitter_user_id})"
|
|
277
|
+
if submitter_user_id else "SUBMITTED_BY: system"),
|
|
278
|
+
f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
|
|
279
|
+
]
|
|
280
|
+
if notify_user_ids:
|
|
281
|
+
lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
|
|
282
|
+
lines += ["", description]
|
|
283
|
+
fpath.write_text("\n".join(lines), encoding="utf-8")
|
|
284
|
+
logger.info("Dropped repo task: %s", fname)
|
|
285
|
+
return fpath
|
|
286
|
+
def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
287
|
+
tasks_dir = project_dir / "repo-tasks"
|
|
288
|
+
if not tasks_dir.exists():
|
|
289
|
+
return []
|
|
290
|
+
tasks = []
|
|
291
|
+
for f in sorted(tasks_dir.iterdir()):
|
|
292
|
+
if not f.is_file() or f.name.startswith(".") or f.suffix.lower() not in (".txt", ".md", ""):
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
content = f.read_text(encoding="utf-8", errors="replace").strip()
|
|
296
|
+
except OSError:
|
|
297
|
+
continue
|
|
298
|
+
if not content:
|
|
299
|
+
continue
|
|
300
|
+
lines = content.splitlines()
|
|
301
|
+
repo_name = ""
|
|
302
|
+
task_type = "feature"
|
|
303
|
+
submitter_user_id = ""
|
|
304
|
+
notify_user_ids: list = []
|
|
305
|
+
body_start = 0
|
|
306
|
+
for i, line in enumerate(lines):
|
|
307
|
+
stripped = line.strip()
|
|
308
|
+
upper = stripped.upper()
|
|
309
|
+
if upper.startswith("REPO:"):
|
|
310
|
+
repo_name = stripped[5:].strip()
|
|
311
|
+
body_start = i + 1
|
|
312
|
+
elif upper.startswith("TYPE:"):
|
|
313
|
+
raw = stripped[5:].strip().lower()
|
|
314
|
+
task_type = raw if raw in ("feature", "fix", "refactor", "chore") else "feature"
|
|
315
|
+
body_start = i + 1
|
|
316
|
+
elif upper.startswith("SUBMITTED_BY:"):
|
|
317
|
+
m = re.search(r'\(([UW][A-Z0-9]+)\)', stripped)
|
|
318
|
+
if m:
|
|
319
|
+
submitter_user_id = m.group(1)
|
|
320
|
+
body_start = i + 1
|
|
321
|
+
elif upper.startswith("NOTIFY:"):
|
|
322
|
+
notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
|
|
323
|
+
body_start = i + 1
|
|
324
|
+
elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
|
|
325
|
+
body_start = i + 1
|
|
326
|
+
else:
|
|
327
|
+
break
|
|
328
|
+
description = "\n".join(lines[body_start:]).strip() or content
|
|
329
|
+
message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
|
|
330
|
+
if not repo_name:
|
|
331
|
+
logger.warning("Repo task %s has no REPO: header — skipping", f.name)
|
|
332
|
+
continue
|
|
333
|
+
tasks.append(RepoTask(
|
|
334
|
+
task_file=f,
|
|
335
|
+
repo_name=repo_name,
|
|
336
|
+
task_type=task_type,
|
|
337
|
+
description=description,
|
|
338
|
+
message=message,
|
|
339
|
+
submitter_user_id=submitter_user_id,
|
|
340
|
+
notify_user_ids=notify_user_ids,
|
|
341
|
+
))
|
|
342
|
+
logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
|
|
343
|
+
return tasks
|
|
344
|
+
def mark_repo_task_done(task_file: Path) -> None:
|
|
345
|
+
done_dir = task_file.parent / ".done"
|
|
346
|
+
done_dir.mkdir(exist_ok=True)
|
|
347
|
+
dest = done_dir / task_file.name
|
|
348
|
+
if dest.exists():
|
|
349
|
+
dest = done_dir / f"{task_file.stem}-{int(time.time())}{task_file.suffix}"
|
|
350
|
+
task_file.rename(dest)
|
|
351
|
+
logger.info("Repo task archived: %s -> .done/%s", task_file.name, dest.name)
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
"state": "compressed",
|
|
5
5
|
"minifiedAt": 1774252437350.0059,
|
|
6
6
|
"readCount": 1
|
|
7
|
+
},
|
|
8
|
+
"J:\\Projects\\Sentinel\\cli\\lib\\upgrade.js": {
|
|
9
|
+
"tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fb78ac_upgrade.js",
|
|
10
|
+
"state": "compressed",
|
|
11
|
+
"minifiedAt": 1774773534448.1794,
|
|
12
|
+
"readCount": 1
|
|
7
13
|
}
|
|
8
14
|
}
|