"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs. Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files (depth-limited, size-capped) and feeds a digest to the LLM. GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or reviews the latest one via LLM. Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint). """ import json import os import re import subprocess import memory_store import llm_backend _speak_fn = print def set_speak(fn): global _speak_fn _speak_fn = fn def _speak(text): try: _speak_fn(text) except Exception as exc: print(f"[dev] speak: {exc}") # ── codebase Q&A ──────────────────────────────────────────────────────────── _EXT_OK = { 'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte', 'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs', 'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml', 'json', 'md', } _SKIP_DIR = { '.git', 'target', 'node_modules', 'dist', 'build', '__pycache__', '.venv', 'venv', '.idea', '.vscode', } _MAX_FILE_BYTES = 4096 _MAX_TOTAL_BYTES = 50_000 _MAX_FILES = 30 _MAX_DEPTH = 3 def _walk_codebase(root: str) -> list[tuple[str, str]]: """Return list of (relative_path, content) tuples — capped per the constants above.""" chunks = [] total_bytes = 0 root = os.path.normpath(root) if not os.path.isdir(root): return chunks for dirpath, dirnames, filenames in os.walk(root): # Depth gate rel = os.path.relpath(dirpath, root) depth = 0 if rel == '.' else rel.count(os.sep) + 1 if depth > _MAX_DEPTH: dirnames[:] = [] continue # Prune skip dirs in-place dirnames[:] = [d for d in dirnames if d not in _SKIP_DIR and not d.startswith('.')] for fname in filenames: if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES: return chunks _, dot_ext = os.path.splitext(fname) ext = (dot_ext or '').lstrip('.').lower() if ext not in _EXT_OK: continue fp = os.path.join(dirpath, fname) try: with open(fp, 'r', encoding='utf-8', errors='replace') as fh: content = fh.read(_MAX_FILE_BYTES + 1) except (OSError, ValueError): continue if len(content) > _MAX_FILE_BYTES: content = content[:_MAX_FILE_BYTES] + "\n... [truncated]" rel_path = os.path.relpath(fp, root) chunks.append((rel_path, content)) total_bytes += len(content) return chunks def do_codebase_set(action, voice): """'Укажи проект C:\\Jarvis\\rust' — stores root via memory.""" body = (voice or '').strip() low = body.lower() for trig in ('укажи папку проекта', 'укажи кодовую базу', 'укажи проект', 'выбери проект', 'проект сейчас'): if low.startswith(trig): body = body[len(trig):].strip(' ,.:') break if not body: _speak("Укажите путь к папке проекта.") return if not os.path.isdir(body): _speak(f"Папка не найдена: {body}.") return memory_store.remember("codebase.root", body) _speak("Проект установлен.") def do_codebase_where(action, voice): root = memory_store.recall("codebase.root") if not root: _speak("Проект не выбран. Скажите: укажи проект и путь.") return _speak(f"Сейчас работаю с проектом {root}.") def do_codebase_ask(action, voice): """'Что делает функция X' / 'найди в коде Y'.""" root = memory_store.recall("codebase.root") if not root: _speak("Сначала укажите проект.") return body = (voice or '').strip() low = body.lower() for trig in ('что делает функция', 'вопрос по коду', 'спроси код', 'найди в проекте', 'найди в коде', 'что в коде', 'объясни код'): idx = low.find(trig) if idx >= 0: body = body[idx + len(trig):].strip(' ,.:') break if not body: _speak("Сформулируйте вопрос.") return chunks = _walk_codebase(root) if not chunks: _speak("Не нашёл исходников в проекте.") return digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks) client = llm_backend.current_client() if client is None: _speak("LLM не настроен.") return model = llm_backend.current_model() user_prompt = ( f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n" f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}" ) try: resp = client.chat.completions.create( model=model, messages=[ {'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'}, {'role': 'user', 'content': user_prompt}, ], max_tokens=400, temperature=0.2, timeout=45, ) answer = resp.choices[0].message.content.strip() except Exception as exc: print(f"[dev] codebase LLM: {exc}") _speak("Не получилось получить ответ.") return if not answer: _speak("Пустой ответ.") return _speak(answer) # ── github PR ─────────────────────────────────────────────────────────────── def _gh(*args, timeout=15): """Run gh CLI, return (success, stdout, stderr).""" try: res = subprocess.run( ['gh', *args], capture_output=True, text=True, encoding='utf-8', timeout=timeout, ) return res.returncode == 0, res.stdout, res.stderr except FileNotFoundError: return False, '', 'gh CLI not installed' except Exception as exc: return False, '', str(exc) def do_github_set_repo(action, voice): body = (voice or '').strip() low = body.lower() for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'): if low.startswith(trig): body = body[len(trig):].strip(' ,.:') break if not body or '/' not in body: _speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.") return memory_store.remember("github.repo", body) _speak(f"Репозиторий {body} установлен.") def do_github_list_prs(action, voice): repo = memory_store.recall("github.repo") if not repo: _speak("Сначала укажите репозиторий: текущий репо owner/repo.") return ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,title,author', '--limit', '10') if not ok: print(f"[dev] gh list: {err[:200]}") _speak("gh CLI не отвечает. Установите gh и выполните gh auth login.") return try: prs = json.loads(out or '[]') except json.JSONDecodeError: prs = [] if not prs: _speak("Открытых пиаров нет.") return line = f"{len(prs)} открытых пиаров. " titles = [p.get('title', '') for p in prs[:3] if p.get('title')] if titles: line += "Первые: " + ". ".join(titles) + "." _speak(line) def do_github_summarize_pr(action, voice): repo = memory_store.recall("github.repo") if not repo: _speak("Сначала укажите репозиторий.") return # Most recent open PR number ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number', '--limit', '1') if not ok: _speak("gh CLI не отвечает.") return try: items = json.loads(out or '[]') number = items[0]['number'] if items else None except (json.JSONDecodeError, KeyError, IndexError): number = None if not number: _speak("Открытых пиаров нет.") return ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo, '--json', 'title,body,additions,deletions,changedFiles,author') if not ok: _speak("Не получилось получить PR.") return try: pr = json.loads(out) except json.JSONDecodeError: _speak("gh вернул битый JSON.") return title = pr.get('title', '(no title)') body = (pr.get('body') or '')[:3000] additions = pr.get('additions', '?') deletions = pr.get('deletions', '?') files = pr.get('changedFiles', '?') author = (pr.get('author') or {}).get('login', '(unknown)') client = llm_backend.current_client() if client is None: _speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.") return model = llm_backend.current_model() user_prompt = ( f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n" f"Changed files: {files} (+{additions}, -{deletions})\n" f"Description:\n{body}\n\n" "Кратко (3-5 предложений) на русском: что меняет этот PR, " "какие риски, стоит ли мёржить." ) try: resp = client.chat.completions.create( model=model, messages=[ {'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."}, {'role': 'user', 'content': user_prompt}, ], max_tokens=400, temperature=0.2, timeout=45, ) review = resp.choices[0].message.content.strip() except Exception as exc: print(f"[dev] PR LLM: {exc}") _speak("Не получилось проанализировать.") return if not review: _speak("Пустая сводка.") return _speak(f"Пиар номер {number}: {review}")