341 lines
12 KiB
Python
341 lines
12 KiB
Python
|
|
"""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
|
|||
|
|
|
|||
|
|
_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)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import config as cfg
|
|||
|
|
except ImportError:
|
|||
|
|
cfg = None
|
|||
|
|
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
|
|||
|
|
if not token:
|
|||
|
|
_speak("LLM не настроен — нужен GROQ_TOKEN.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from openai import OpenAI
|
|||
|
|
except ImportError:
|
|||
|
|
_speak("OpenAI клиент не установлен.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
client = OpenAI(
|
|||
|
|
api_key=token,
|
|||
|
|
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
user_prompt = (
|
|||
|
|
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
|
|||
|
|
f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n"
|
|||
|
|
f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
resp = client.chat.completions.create(
|
|||
|
|
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
|
|||
|
|
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)')
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import config as cfg
|
|||
|
|
except ImportError:
|
|||
|
|
cfg = None
|
|||
|
|
token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None
|
|||
|
|
if not token:
|
|||
|
|
_speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from openai import OpenAI
|
|||
|
|
except ImportError:
|
|||
|
|
_speak("OpenAI клиент не установлен.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
client = OpenAI(
|
|||
|
|
api_key=token,
|
|||
|
|
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
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=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
|
|||
|
|
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}")
|