@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.
Files changed (38) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +13 -1
  3. package/.cairn/session.json +2 -2
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/5f5141_main.py +1067 -0
  6. package/.cairn/views/62a614_bundle.js +4 -1
  7. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  8. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  9. package/lib/.cairn/minify-map.json +6 -0
  10. package/lib/.cairn/views/2a85cc_init.js +380 -0
  11. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  12. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  13. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  14. package/lib/init.js +54 -0
  15. package/lib/maven.js +212 -0
  16. package/lib/slack-setup.js +5 -0
  17. package/package.json +1 -1
  18. package/python/requirements.txt +1 -0
  19. package/python/sentinel/.cairn/.cairn-project +0 -0
  20. package/python/sentinel/.cairn/.hint-lock +1 -0
  21. package/python/sentinel/.cairn/session.json +9 -0
  22. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  23. package/python/sentinel/config_loader.py +29 -10
  24. package/python/sentinel/dependency_manager.py +9 -2
  25. package/python/sentinel/git_manager.py +23 -0
  26. package/python/sentinel/issue_watcher.py +7 -1
  27. package/python/sentinel/main.py +353 -8
  28. package/python/sentinel/notify.py +44 -12
  29. package/python/sentinel/repo_task_engine.py +49 -7
  30. package/python/sentinel/sentinel_boss.py +117 -3
  31. package/python/sentinel/slack_bot.py +15 -2
  32. package/python/sentinel/state_store.py +0 -1
  33. package/python/tests/__init__.py +0 -0
  34. package/python/tests/test_config_loader.py +138 -0
  35. package/python/tests/test_log_parser.py +62 -0
  36. package/python/tests/test_repo_router.py +73 -0
  37. package/python/tests/test_smoke.py +96 -0
  38. 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, { overwrite: true });
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
  }