@misterhuydo/sentinel 1.4.90 → 1.4.91

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