""" Evolver webhook server — lightweight HTTP server that receives GitHub webhook events (issue_comment) and dispatches Claude CLI sessions to respond. Also runs daily cron schedules for automated postmortem (replayer skill) and autofix (autofix skill). """ import hashlib import hmac import json import logging import os import subprocess import threading import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone from http.server import HTTPServer, BaseHTTPRequestHandler logging.basicConfig( level=logging.INFO, format="[%(asctime)s] %(levelname)s %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("evolver") REPO_DIR = "/app/repo" WEBHOOK_SECRET = os.environ.get("EVOLVER_WEBHOOK_SECRET", "") def _env_bool(name: str, default: bool) -> bool: raw = os.environ.get(name) if raw is None: return default return raw.strip().lower() not in ("0", "false", "no", "off", "") def _env_weekdays(name: str) -> set[int]: """Parse UTC weekdays. Monday is 0; accepts names such as mon,wed,fri.""" raw = os.environ.get(name, "*").strip().lower() if not raw or raw in ("*", "all", "daily"): return set(range(7)) names = { "mon": 0, "monday": 0, "tue": 1, "tuesday": 1, "wed": 2, "wednesday": 2, "thu": 3, "thursday": 3, "fri": 4, "friday": 4, "sat": 5, "saturday": 5, "sun": 6, "sunday": 6, } weekdays: set[int] = set() for item in raw.split(","): token = item.strip() if not token: continue if token in names: weekdays.add(names[token]) continue try: value = int(token) except ValueError: log.warning("Invalid weekday token for %s: %s", name, token) continue if 0 <= value <= 6: weekdays.add(value) else: log.warning("Weekday token out of range for %s: %s", name, token) return weekdays or set(range(7)) def _format_weekdays(weekdays: set[int]) -> str: if weekdays == set(range(7)): return "daily" labels = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] return ",".join(labels[i] for i in sorted(weekdays)) POSTMORTEM_ENABLED = _env_bool("EVOLVER_POSTMORTEM_ENABLED", True) POSTMORTEM_CRON_HOUR_UTC = int( os.environ.get( "EVOLVER_POSTMORTEM_CRON_HOUR_UTC", os.environ.get("EVOLVER_CRON_HOUR_UTC", "2"), ) ) POSTMORTEM_CRON_WEEKDAYS_UTC = _env_weekdays("EVOLVER_POSTMORTEM_CRON_WEEKDAYS_UTC") AUTOFIX_ENABLED = _env_bool("EVOLVER_AUTOFIX_ENABLED", False) AUTOFIX_CRON_HOUR_UTC = int(os.environ.get("EVOLVER_AUTOFIX_CRON_HOUR_UTC", "6")) AUTOFIX_CRON_WEEKDAYS_UTC = _env_weekdays("EVOLVER_AUTOFIX_CRON_WEEKDAYS_UTC") AUTOFIX_TIMEOUT_SECONDS = int(os.environ.get("EVOLVER_AUTOFIX_TIMEOUT_SECONDS", "14400")) # Lock to prevent concurrent Claude sessions (not idempotent) _session_lock = threading.Lock() _inspection_lock = threading.Lock() DEDICATED_INSPECTION_ENABLED = _env_bool("DEDICATED_INSPECTION_ENABLED", False) DEDICATED_INSPECTION_CRON_HOUR_UTC = int( os.environ.get("DEDICATED_INSPECTION_CRON_HOUR_UTC", "1") ) DEDICATED_INSPECTION_CRON_MINUTE_UTC = int( os.environ.get("DEDICATED_INSPECTION_CRON_MINUTE_UTC", "0") ) DEDICATED_INSPECTION_BASE_URL = os.environ.get( "DEDICATED_INSPECTION_BASE_URL", "" ).rstrip("/") DEDICATED_INSPECTION_WORKSPACE_ID = os.environ.get( "DEDICATED_INSPECTION_WORKSPACE_ID", "" ).strip() DEDICATED_INSPECTION_API_TOKEN = os.environ.get( "DEDICATED_INSPECTION_API_TOKEN", "" ).strip() DEDICATED_INSPECTION_CLUSTER_IDS = [ item.strip() for item in os.environ.get("DEDICATED_INSPECTION_CLUSTER_IDS", "").split(",") if item.strip() ] DEDICATED_INSPECTION_MODEL_TIER = os.environ.get( "DEDICATED_INSPECTION_MODEL_TIER", "standard" ).strip() or "standard" DEDICATED_INSPECTION_TIMEOUT_SECONDS = int( os.environ.get("DEDICATED_INSPECTION_TIMEOUT_SECONDS", "1800") ) DEDICATED_INSPECTION_TRIGGER_TOKEN = os.environ.get( "DEDICATED_INSPECTION_TRIGGER_TOKEN", "" ).strip() def verify_github_signature(payload: bytes, signature: str) -> bool: """Verify GitHub webhook HMAC-SHA256 signature.""" if not WEBHOOK_SECRET: return True # Skip verification if no secret configured if not signature.startswith("sha256="): return False expected = hmac.new( WEBHOOK_SECRET.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(f"sha256={expected}", signature) def git_pull(): """Pull latest main.""" log.info("Pulling latest main...") subprocess.run( ["git", "fetch", "origin", "main"], cwd=REPO_DIR, check=True, capture_output=True, ) subprocess.run( ["git", "reset", "--hard", "origin/main"], cwd=REPO_DIR, check=True, capture_output=True, ) log.info("Updated to latest main") def run_claude(prompt: str, session_name: str, timeout_seconds: int = 3600) -> str: """Run a Claude CLI session and return the output.""" log_file = f"/tmp/evolver-{session_name}.log" log.info("Starting Claude session: %s", session_name) result = subprocess.run( [ "claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep", ], cwd=REPO_DIR, capture_output=True, text=True, timeout=timeout_seconds, ) output = result.stdout + "\n" + result.stderr with open(log_file, "w") as f: f.write(output) log.info("Claude session %s exited with code %d", session_name, result.returncode) return output def ensure_github_token_file() -> None: """Write the env token to the ignored token file expected by repo tools.""" token = os.environ.get("GITHUB_TOKEN", "") if not token: log.warning("GITHUB_TOKEN is not set; repo tools may skip GitHub operations") return token_path = os.path.join(REPO_DIR, "tools", "replayer", ".github_token") with open(token_path, "w") as f: f.write(token) os.chmod(token_path, 0o600) def run_daily_postmortem(): """Execute the daily replayer postmortem workflow.""" if not _session_lock.acquire(blocking=False): log.warning("Another session is running, skipping daily postmortem") return try: date = datetime.now(timezone.utc).strftime("%Y-%m-%d") log.info("=== Daily postmortem started (%s) ===", date) git_pull() prompt = ( "Run replayer skill per tools/replayer/SKILL.md. Execute Phase 0-8.\n" "Use --profile local for workspace API access.\n" "GITHUB_TOKEN is already set in the environment.\n" "Commit any changes to issues.yaml, postmortem.md, expected.md to a branch\n" f"evolver/daily-{date} and push. Create a PR if there are changes." ) run_claude(prompt, f"daily-{date}") # Push results if there are changes diff = subprocess.run( ["git", "diff", "--quiet", "HEAD"], cwd=REPO_DIR, capture_output=True, ) if diff.returncode != 0: branch = f"evolver/daily-{date}" log.info("Changes detected, pushing to %s", branch) subprocess.run( ["git", "checkout", "-b", branch], cwd=REPO_DIR, capture_output=True, ) subprocess.run( ["git", "add", "tools/replayer/issues/", "tools/replayer/cases/", "tools/replayer/results/"], cwd=REPO_DIR, capture_output=True, ) subprocess.run( ["git", "commit", "-m", f"evolver: daily postmortem {date}\n\nCo-Authored-By: Claude Opus 4.6 "], cwd=REPO_DIR, capture_output=True, ) subprocess.run( ["git", "push", "origin", branch], cwd=REPO_DIR, check=True, capture_output=True, ) log.info("Pushed to %s", branch) else: log.info("No changes to commit") log.info("=== Daily postmortem finished (%s) ===", date) except Exception: log.exception("Daily postmortem failed") finally: _session_lock.release() def run_daily_autofix(): """Execute the daily autofix workflow.""" if not _session_lock.acquire(blocking=False): log.warning("Another session is running, skipping daily autofix") return try: date = datetime.now(timezone.utc).strftime("%Y-%m-%d") log.info("=== Daily autofix started (%s) ===", date) git_pull() ensure_github_token_file() prompt = ( "Run autofix skill per tools/autofix/SKILL.md.\n" "Fix all eligible issues from tools/replayer/issues/issues.yaml.\n" "Use tools/autofix/run.sh so each issue gets its own worktree, " "branch, and PR.\n" "Do not impose a max issue limit.\n" "GITHUB_TOKEN is already set in the environment, and " "tools/replayer/.github_token has been prepared from it.\n" "Exit cleanly if no eligible issues." ) run_claude(prompt, f"autofix-{date}", timeout_seconds=AUTOFIX_TIMEOUT_SECONDS) log.info("=== Daily autofix finished (%s) ===", date) except Exception: log.exception("Daily autofix failed") finally: # Leave the main checkout ready for webhook sessions and postmortem. subprocess.run(["git", "checkout", "main"], cwd=REPO_DIR, capture_output=True) subprocess.run(["git", "reset", "--hard", "origin/main"], cwd=REPO_DIR, capture_output=True) _session_lock.release() def _shanghai_report_date() -> str: return (datetime.now(timezone.utc) + timedelta(hours=8)).strftime("%Y-%m-%d") def _shanghai_noon_window() -> tuple[str, str]: now_shanghai = datetime.now(timezone.utc) + timedelta(hours=8) end = now_shanghai.replace(hour=12, minute=0, second=0, microsecond=0) start = end - timedelta(days=1) return start.strftime("%Y-%m-%d %H:%M"), end.strftime("%Y-%m-%d %H:%M") def _build_dedicated_inspection_prompt(report_date: str) -> str: cluster_lines = "\n".join(f"- {cluster_id}" for cluster_id in DEDICATED_INSPECTION_CLUSTER_IDS) window_start, window_end = _shanghai_noon_window() custom_prompt = os.environ.get("DEDICATED_INSPECTION_PROMPT", "").strip() if custom_prompt: return ( custom_prompt .replace("{report_date}", report_date) .replace("{cluster_ids}", cluster_lines) .replace("{window_start}", window_start) .replace("{window_end}", window_end) ) return ( "请使用 tidbcloud-dedicated-daily-inspection skill,对下面的 TiDB Cloud " "Dedicated 集群做每日巡检。\n\n" f"cluster_ids:\n{cluster_lines}\n\n" f"report_date: {report_date}\n" "timezone: Asia/Shanghai\n\n" f"巡检窗口:{window_start} 到 {window_end},时区 Asia/Shanghai。\n\n" "要求:\n" "1. 必须运行 `tidbcloud-dedicated-daily-inspection/scripts/dedicated_daily_inspection.py` 生成巡检报告。\n" "2. 若脚本失败,先修复脚本运行环境并重跑。\n" "3. 如果脚本因为并发过高、rate limit、HTTP 503 或上游服务过载失败,必须先查看脚本 help," "用更低并发重跑;如果脚本没有并发参数,则拆分 cluster 串行重跑。\n" "4. 禁止手工用 `clinic-cli` 拼凑巡检结论或生成最终报告。\n" "5. 最终 Feishu 文档只能基于脚本成功输出的结果。\n" "6. 同时读取 references/inspection-rules.md。\n" "7. 生成英文 daily inspection report,每个 cluster 单独一节。\n" "8. 如果指标缺失,需要明确写出 missing data,而不是猜测。" ) def _dedicated_inspection_config_errors() -> list[str]: errors = [] if not DEDICATED_INSPECTION_BASE_URL: errors.append("DEDICATED_INSPECTION_BASE_URL is not set") if not DEDICATED_INSPECTION_WORKSPACE_ID: errors.append("DEDICATED_INSPECTION_WORKSPACE_ID is not set") if not DEDICATED_INSPECTION_API_TOKEN: errors.append("DEDICATED_INSPECTION_API_TOKEN is not set") if not DEDICATED_INSPECTION_CLUSTER_IDS: errors.append("DEDICATED_INSPECTION_CLUSTER_IDS is not set") return errors def run_dedicated_daily_inspection(trigger: str = "manual") -> None: """Trigger the prod Investibook workspace chat API for Dedicated inspection.""" if not _inspection_lock.acquire(blocking=False): log.warning("Dedicated inspection already running; skip trigger=%s", trigger) return try: config_errors = _dedicated_inspection_config_errors() if config_errors: log.error("Dedicated inspection config invalid: %s", "; ".join(config_errors)) return report_date = _shanghai_report_date() prompt = _build_dedicated_inspection_prompt(report_date) params = urllib.parse.urlencode( { "workspace_id": DEDICATED_INSPECTION_WORKSPACE_ID, "model_tier": DEDICATED_INSPECTION_MODEL_TIER, "message": prompt, } ) url = f"{DEDICATED_INSPECTION_BASE_URL}/api/chat/stream?{params}" request = urllib.request.Request( url, headers={ "Authorization": f"Bearer {DEDICATED_INSPECTION_API_TOKEN}", "Accept": "text/event-stream", }, method="GET", ) log.info( "Dedicated inspection started trigger=%s workspace_id=%s report_date=%s clusters=%d", trigger, DEDICATED_INSPECTION_WORKSPACE_ID, report_date, len(DEDICATED_INSPECTION_CLUSTER_IDS), ) session_id = "" complete = False errors: list[str] = [] with urllib.request.urlopen( request, timeout=DEDICATED_INSPECTION_TIMEOUT_SECONDS ) as response: for raw_line in response: line = raw_line.decode("utf-8", "replace").strip() if not line.startswith("data:"): continue try: event = json.loads(line[len("data:") :].strip()) except json.JSONDecodeError: continue event_type = event.get("type") if event_type == "session_created": session_id = str(event.get("session_id") or "") log.info( "Dedicated inspection session created workspace_id=%s session_id=%s", DEDICATED_INSPECTION_WORKSPACE_ID, session_id, ) elif event_type == "error": message = str(event.get("error") or event.get("code") or "unknown") errors.append(message[:300]) log.error("Dedicated inspection stream error: %s", message[:300]) elif event_type == "complete": complete = True break if errors: log.error( "Dedicated inspection finished with errors workspace_id=%s session_id=%s errors=%s", DEDICATED_INSPECTION_WORKSPACE_ID, session_id or "-", "; ".join(errors), ) elif complete: log.info( "Dedicated inspection completed workspace_id=%s session_id=%s report_date=%s", DEDICATED_INSPECTION_WORKSPACE_ID, session_id or "-", report_date, ) else: log.warning( "Dedicated inspection stream ended without complete workspace_id=%s session_id=%s", DEDICATED_INSPECTION_WORKSPACE_ID, session_id or "-", ) except urllib.error.HTTPError as e: body = e.read(500).decode("utf-8", "replace") log.error("Dedicated inspection HTTP error status=%s body=%s", e.code, body) except Exception: log.exception("Dedicated inspection failed") finally: _inspection_lock.release() def handle_issue_comment(payload: dict): """Handle a GitHub issue_comment event — run Claude to respond.""" action = payload.get("action") if action != "created": return comment = payload.get("comment", {}) body = comment.get("body", "") user = comment.get("user", {}).get("login", "") issue = payload.get("issue", {}) issue_number = issue.get("number") issue_title = issue.get("title", "") issue_body = issue.get("body", "") repo = payload.get("repository", {}).get("full_name", "") # Only respond to comments that mention @evolver-bot if "@evolver-bot" not in body: log.info("Ignoring comment on #%s (no @evolver-bot mention)", issue_number) return if not _session_lock.acquire(blocking=False): log.warning("Another session is running, cannot handle comment on #%s", issue_number) # Post a reply saying busy _post_github_comment( repo, issue_number, "I'm currently running another task. Please try again in a few minutes." ) return try: log.info("Handling @evolver-bot mention on %s#%s by %s", repo, issue_number, user) git_pull() # Collect prior comments for context comments_context = _fetch_issue_comments(repo, issue_number) prompt = ( f"You are the Evolver bot responding to a GitHub issue comment.\n\n" f"## Issue #{issue_number}: {issue_title}\n\n" f"{issue_body}\n\n" f"## Comment history\n{comments_context}\n\n" f"## Latest comment by @{user}:\n{body}\n\n" f"## Instructions\n" f"1. Read the replayer skill at tools/replayer/SKILL.md for context\n" f"2. Address the user's feedback or question\n" f"3. If the user is correcting a diagnosis, update the relevant files " f"(issues.yaml, postmortem.md, expected.md)\n" f"4. Output your response as a GitHub-flavored markdown comment\n\n" f"After making changes, commit to a branch evolver/issue-{issue_number} and push." ) output = run_claude(prompt, f"issue-{issue_number}-{int(time.time())}") # Extract a reasonable response (last ~2000 chars of output) response = output.strip()[-2000:] if len(output) > 2000 else output.strip() # Push changes if any diff = subprocess.run( ["git", "diff", "--quiet", "HEAD"], cwd=REPO_DIR, capture_output=True, ) if diff.returncode != 0: branch = f"evolver/issue-{issue_number}" subprocess.run(["git", "checkout", "-b", branch], cwd=REPO_DIR, capture_output=True) subprocess.run(["git", "add", "-A"], cwd=REPO_DIR, capture_output=True) subprocess.run( ["git", "commit", "-m", f"evolver: respond to #{issue_number}\n\nCo-Authored-By: Claude Opus 4.6 "], cwd=REPO_DIR, capture_output=True, ) subprocess.run(["git", "push", "-f", "origin", branch], cwd=REPO_DIR, capture_output=True) response += f"\n\n---\nChanges pushed to branch `{branch}`." _post_github_comment(repo, issue_number, response) except Exception: log.exception("Failed to handle comment on #%s", issue_number) _post_github_comment( repo, issue_number, "Sorry, I encountered an error processing your request. Check the evolver pod logs for details." ) finally: # Reset back to main for next task subprocess.run(["git", "checkout", "main"], cwd=REPO_DIR, capture_output=True) subprocess.run(["git", "reset", "--hard", "origin/main"], cwd=REPO_DIR, capture_output=True) _session_lock.release() def _fetch_issue_comments(repo: str, issue_number: int) -> str: """Fetch issue comments via GitHub API.""" token = os.environ.get("GITHUB_TOKEN", "") try: result = subprocess.run( ["curl", "-s", "-H", f"Authorization: token {token}", "-H", "Accept: application/vnd.github.v3+json", f"https://api.github.com/repos/{repo}/issues/{issue_number}/comments?per_page=20"], capture_output=True, text=True, timeout=30, ) comments = json.loads(result.stdout) lines = [] for c in comments[-10:]: # Last 10 comments u = c.get("user", {}).get("login", "unknown") b = c.get("body", "")[:500] lines.append(f"**@{u}**: {b}") return "\n\n".join(lines) if lines else "(no prior comments)" except Exception: log.exception("Failed to fetch comments for #%s", issue_number) return "(failed to fetch comments)" def _post_github_comment(repo: str, issue_number: int, body: str): """Post a comment on a GitHub issue.""" token = os.environ.get("GITHUB_TOKEN", "") try: payload = json.dumps({"body": body}) subprocess.run( ["curl", "-s", "-X", "POST", "-H", f"Authorization: token {token}", "-H", "Accept: application/vnd.github.v3+json", "-d", payload, f"https://api.github.com/repos/{repo}/issues/{issue_number}/comments"], capture_output=True, text=True, timeout=30, ) log.info("Posted comment on %s#%s", repo, issue_number) except Exception: log.exception("Failed to post comment on #%s", issue_number) class WebhookHandler(BaseHTTPRequestHandler): def _write_json(self, status: int, payload: dict): self.send_response(status) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(payload).encode()) def _verify_dedicated_inspection_trigger(self) -> bool: if not DEDICATED_INSPECTION_TRIGGER_TOKEN: self._write_json( 503, { "ok": False, "error": "DEDICATED_INSPECTION_TRIGGER_TOKEN is not configured", }, ) return False auth = self.headers.get("Authorization", "") token = "" if auth.startswith("Bearer "): token = auth[len("Bearer ") :].strip() token = token or self.headers.get("X-Evolver-Token", "").strip() if not hmac.compare_digest(token, DEDICATED_INSPECTION_TRIGGER_TOKEN): self._write_json(401, {"ok": False, "error": "unauthorized"}) return False return True def do_GET(self): if self.path in ("/healthz", "/evolver/healthz"): self._write_json( 200, { "status": "ok", "session_busy": _session_lock.locked(), "dedicated_inspection_running": _inspection_lock.locked(), }, ) elif self.path in ( "/dedicated-inspection/status", "/evolver/dedicated-inspection/status", ): if not self._verify_dedicated_inspection_trigger(): return self._write_json( 200, { "enabled": DEDICATED_INSPECTION_ENABLED, "running": _inspection_lock.locked(), "cron_hour_utc": DEDICATED_INSPECTION_CRON_HOUR_UTC, "cron_minute_utc": DEDICATED_INSPECTION_CRON_MINUTE_UTC, "base_url_configured": bool(DEDICATED_INSPECTION_BASE_URL), "workspace_id": DEDICATED_INSPECTION_WORKSPACE_ID, "api_token_configured": bool(DEDICATED_INSPECTION_API_TOKEN), "trigger_token_configured": bool(DEDICATED_INSPECTION_TRIGGER_TOKEN), "cluster_count": len(DEDICATED_INSPECTION_CLUSTER_IDS), "config_errors": _dedicated_inspection_config_errors(), }, ) else: self.send_response(404) self.end_headers() def do_POST(self): if self.path in ( "/dedicated-inspection/run", "/evolver/dedicated-inspection/run", ): if not self._verify_dedicated_inspection_trigger(): return if _inspection_lock.locked(): self._write_json( 409, {"ok": False, "error": "dedicated inspection already running"}, ) return threading.Thread( target=run_dedicated_daily_inspection, kwargs={"trigger": "manual"}, daemon=True, ).start() self._write_json( 202, { "ok": True, "status": "started", "workspace_id": DEDICATED_INSPECTION_WORKSPACE_ID, "cluster_count": len(DEDICATED_INSPECTION_CLUSTER_IDS), }, ) return # Accept both /webhook/github (direct) and /evolver/webhook/github (via ingress) if self.path not in ("/webhook/github", "/evolver/webhook/github"): self.send_response(404) self.end_headers() return content_length = int(self.headers.get("Content-Length", 0)) payload_bytes = self.rfile.read(content_length) # Verify signature signature = self.headers.get("X-Hub-Signature-256", "") if not verify_github_signature(payload_bytes, signature): log.warning("Invalid webhook signature") self.send_response(401) self.end_headers() return event = self.headers.get("X-GitHub-Event", "") log.info("Received webhook event: %s", event) # Respond 200 immediately, process async self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"ok":true}') try: payload = json.loads(payload_bytes) except json.JSONDecodeError: log.error("Invalid JSON payload") return if event == "issue_comment": threading.Thread( target=handle_issue_comment, args=(payload,), daemon=True, ).start() else: log.info("Ignoring event type: %s", event) def log_message(self, format, *args): # Suppress default access logs, we have our own logging pass def cron_loop(): """Simple cron loop — triggers configured daily jobs.""" log.info( "Cron loop started (postmortem=%s at %02d:00 UTC weekdays=%s, autofix=%s at %02d:00 UTC weekdays=%s, dedicated_inspection=%s at %02d:%02d UTC)", "enabled" if POSTMORTEM_ENABLED else "disabled", POSTMORTEM_CRON_HOUR_UTC, _format_weekdays(POSTMORTEM_CRON_WEEKDAYS_UTC), "enabled" if AUTOFIX_ENABLED else "disabled", AUTOFIX_CRON_HOUR_UTC, _format_weekdays(AUTOFIX_CRON_WEEKDAYS_UTC), "enabled" if DEDICATED_INSPECTION_ENABLED else "disabled", DEDICATED_INSPECTION_CRON_HOUR_UTC, DEDICATED_INSPECTION_CRON_MINUTE_UTC, ) last_postmortem_run_date = None last_autofix_run_date = None last_dedicated_inspection_run_date = None while True: now = datetime.now(timezone.utc) today = now.strftime("%Y-%m-%d") if ( POSTMORTEM_ENABLED and now.hour == POSTMORTEM_CRON_HOUR_UTC and now.weekday() in POSTMORTEM_CRON_WEEKDAYS_UTC and last_postmortem_run_date != today ): last_postmortem_run_date = today log.info("Cron trigger: starting daily postmortem") threading.Thread(target=run_daily_postmortem, daemon=True).start() if ( AUTOFIX_ENABLED and now.hour == AUTOFIX_CRON_HOUR_UTC and now.weekday() in AUTOFIX_CRON_WEEKDAYS_UTC and last_autofix_run_date != today ): last_autofix_run_date = today log.info("Cron trigger: starting daily autofix") threading.Thread(target=run_daily_autofix, daemon=True).start() if ( DEDICATED_INSPECTION_ENABLED and now.hour == DEDICATED_INSPECTION_CRON_HOUR_UTC and now.minute == DEDICATED_INSPECTION_CRON_MINUTE_UTC and last_dedicated_inspection_run_date != today ): last_dedicated_inspection_run_date = today log.info("Cron trigger: starting dedicated daily inspection") threading.Thread( target=run_dedicated_daily_inspection, kwargs={"trigger": "cron"}, daemon=True, ).start() time.sleep(60) # Check every minute def main(): port = int(os.environ.get("EVOLVER_LISTEN_PORT", "8080")) # Initial setup os.chdir(REPO_DIR) # Configure git subprocess.run(["git", "config", "user.name", "evolver-bot"], cwd=REPO_DIR) subprocess.run(["git", "config", "user.email", "evolver-bot@investibook.local"], cwd=REPO_DIR) github_token = os.environ.get("GITHUB_TOKEN", "") if github_token: subprocess.run( ["git", "remote", "set-url", "origin", f"https://x-access-token:{github_token}@github.com/tidbcloud/investibook.git"], cwd=REPO_DIR, ) # Pull latest try: git_pull() except Exception: log.warning("Initial git pull failed, continuing with built-in code") # Set up investibook CLI profile os.makedirs(os.path.expanduser("~/.investibook"), exist_ok=True) api_url = os.environ.get("INVESTIBOOK_BASE_URL", "http://backend:8000") api_token = os.environ.get("INVESTIBOOK_API_TOKEN", "") with open(os.path.expanduser("~/.investibook/config.json"), "w") as f: json.dump({ "profiles": { "local": {"api": api_url, "token": api_token} }, "active_profile": "local", }, f, indent=2) # Start cron loop in background threading.Thread(target=cron_loop, daemon=True).start() # Start HTTP server server = HTTPServer(("0.0.0.0", port), WebhookHandler) log.info("Evolver webhook server listening on :%d", port) log.info(" GET /healthz — health check") log.info(" POST /webhook/github — GitHub webhook receiver") log.info(" GET /dedicated-inspection/status — dedicated inspection config") log.info(" POST /dedicated-inspection/run — manual dedicated inspection trigger") server.serve_forever() if __name__ == "__main__": main()