first commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
AIMTR_BASE_URL=https://aimtr.wellflow.dev/v1
|
||||
AIMTR_API_KEY=sk-ant-api01-QLHWXU5RS96yRMNnFJ2VlN5UmR5L1t5KGTMnyr8wAHhWpxWFJrP9FctzZtaZHvPk
|
||||
AIMTR_MODEL=claude-haiku-4.5
|
||||
|
||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||
TAIGA_USERNAME=aibot
|
||||
TAIGA_PASSWORD=uhbujhbq576
|
||||
|
||||
GITEA_BASE_URL=http://host.docker.internal:3000
|
||||
GITEA_SSH_BASE=ssh://git@host.docker.internal:222
|
||||
|
||||
AGENT_ALLOW_GIT_PUSH=false
|
||||
AGENT_ALLOW_DELETE=false
|
||||
AGENT_ALLOW_JENKINS_BUILD=false
|
||||
@@ -0,0 +1 @@
|
||||
repos/
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
openssh-client \
|
||||
ca-certificates \
|
||||
curl \
|
||||
jq \
|
||||
ripgrep \
|
||||
tree \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 agent
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
USER agent
|
||||
|
||||
CMD ["uvicorn", "project_agent.server:app", "--host", "0.0.0.0", "--port", "8787"]
|
||||
@@ -0,0 +1,36 @@
|
||||
projects:
|
||||
AISHub:
|
||||
taiga_project_id: 5
|
||||
repo_url: ssh://git@host.docker.internal:222/Grigo/AISHub.git
|
||||
repo_path: /repos/AISHub
|
||||
default_branch: main
|
||||
|
||||
AIsMas-Web-Service:
|
||||
taiga_project_id: 4
|
||||
repo_url: ssh://git@host.docker.internal:222/Grigo/WebAisMap.git
|
||||
repo_path: /repos/AIsMas-Web-Service
|
||||
default_branch: main
|
||||
|
||||
AndroidAisMap:
|
||||
taiga_project_id: 3
|
||||
repo_url: ssh://git@host.docker.internal:222/Grigo/AndroidAisMap.git
|
||||
repo_path: /repos/AndroidAisMap
|
||||
default_branch: main
|
||||
|
||||
PrivateTest:
|
||||
taiga_project_id: 6
|
||||
repo_url: ssh://git@host.docker.internal:222/Grigo/PrivateTest.git
|
||||
repo_path: /repos/PrivateTest
|
||||
default_branch: main
|
||||
|
||||
Testing:
|
||||
taiga_project_id: 1
|
||||
repo_url: ssh://git@host.docker.internal:222/Grigo/Testing.git
|
||||
repo_path: /repos/Testing
|
||||
default_branch: main
|
||||
|
||||
ClawSetUp:
|
||||
taiga_project_id: 8
|
||||
repo_url:
|
||||
repo_path:
|
||||
default_branch:
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,773 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
app = FastAPI(title="Project Agent")
|
||||
|
||||
CONFIG_PATH = "/app/config.yml"
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
project: str | None = None
|
||||
|
||||
|
||||
class TaskFromCodeRequest(BaseModel):
|
||||
project: str
|
||||
text: str
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: str | None = None, timeout: int = 120):
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"cmd": cmd,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
|
||||
|
||||
def strip_markdown_json(text: str) -> str:
|
||||
text = text.strip()
|
||||
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
|
||||
if fenced:
|
||||
return fenced.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def sync_one_project(project: dict):
|
||||
repo_url = project.get("repo_url")
|
||||
repo_path = project.get("repo_path")
|
||||
|
||||
if not repo_url:
|
||||
return {"skipped": True, "reason": "repo_url is empty"}
|
||||
|
||||
if not repo_path:
|
||||
return {"skipped": True, "reason": "repo_path is empty"}
|
||||
|
||||
if os.path.exists(repo_path):
|
||||
fetch = run(["git", "fetch", "--all", "--prune"], cwd=repo_path)
|
||||
pull = run(["git", "pull", "--ff-only"], cwd=repo_path)
|
||||
return {
|
||||
"action": "pull",
|
||||
"fetch": fetch,
|
||||
"pull": pull,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
clone = run(["git", "clone", repo_url, repo_path])
|
||||
return {
|
||||
"action": "clone",
|
||||
"clone": clone,
|
||||
}
|
||||
|
||||
|
||||
def read_file_safe(path: str, max_chars: int = 12000) -> str:
|
||||
try:
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
if os.path.getsize(path) > 512 * 1024:
|
||||
return ""
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read(max_chars)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def collect_repo_context(repo_path: str, task_text: str) -> str:
|
||||
parts = []
|
||||
|
||||
tree = run(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
"find . -maxdepth 4 "
|
||||
"-not -path './.git/*' "
|
||||
"-not -path './node_modules/*' "
|
||||
"-not -path './vendor/*' "
|
||||
"-not -path './dist/*' "
|
||||
"-not -path './build/*' "
|
||||
"-not -path './target/*' "
|
||||
"| sort | head -300",
|
||||
],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
parts.append("## File tree\n" + tree["stdout"])
|
||||
|
||||
git_log = run(
|
||||
["git", "log", "--oneline", "-15"],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
parts.append("## Recent commits\n" + git_log["stdout"])
|
||||
|
||||
candidate_files = [
|
||||
"README.md",
|
||||
"readme.md",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"compose.yml",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"settings.gradle",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
".gitignore",
|
||||
]
|
||||
|
||||
for rel in candidate_files:
|
||||
content = read_file_safe(os.path.join(repo_path, rel), max_chars=10000)
|
||||
if content:
|
||||
parts.append(f"## {rel}\n{content}")
|
||||
|
||||
words = []
|
||||
for word in re.findall(r"[A-Za-zА-Яа-я0-9_/-]{4,}", task_text):
|
||||
word = word.strip().lower()
|
||||
if word not in words:
|
||||
words.append(word)
|
||||
words = words[:8]
|
||||
|
||||
if words:
|
||||
pattern = "|".join(re.escape(w) for w in words)
|
||||
grep = run(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
f"rg -n -i --glob '!node_modules' --glob '!vendor' --glob '!dist' --glob '!build' --glob '!target' \"{pattern}\" . | head -80",
|
||||
],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
if grep["stdout"]:
|
||||
parts.append("## Relevant grep matches\n" + grep["stdout"])
|
||||
|
||||
context = "\n\n".join(parts)
|
||||
return context[:50000]
|
||||
|
||||
|
||||
def aimtr_make_task(raw_text: str, repo_context: str) -> dict:
|
||||
base_url = os.getenv("AIMTR_BASE_URL", "").rstrip("/")
|
||||
api_key = os.getenv("AIMTR_API_KEY")
|
||||
model = os.getenv("AIMTR_MODEL", "claude-haiku-4.5")
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(status_code=500, detail="AIMTR_BASE_URL or AIMTR_API_KEY is missing")
|
||||
|
||||
system_prompt = """
|
||||
Ты технический ассистент и тимлид.
|
||||
Тебе дают описание задачи и краткий контекст репозитория.
|
||||
Сформируй задачу для Taiga с учетом структуры кода.
|
||||
|
||||
Отвечай только валидным JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"title": "короткое название",
|
||||
"description": "описание с учетом контекста кода",
|
||||
"type": "Story",
|
||||
"priority": "low|normal|high",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"acceptance_criteria": [
|
||||
"проверяемый критерий"
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"title": "техническая подзадача",
|
||||
"description": "что сделать и где примерно смотреть в коде",
|
||||
"type": "Task",
|
||||
"priority": "low|normal|high"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
"уточняющий вопрос, если данных не хватает"
|
||||
],
|
||||
"code_notes": [
|
||||
"заметка по найденному контексту кода"
|
||||
]
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Пиши на русском.
|
||||
- Не выдумывай файлы, если их нет в контексте.
|
||||
- Если предполагаешь файл или модуль, явно пиши 'вероятно' или 'проверить'.
|
||||
- Acceptance criteria должны быть проверяемыми.
|
||||
- Подзадачи должны быть полезны разработчику.
|
||||
""".strip()
|
||||
|
||||
user_prompt = f"""
|
||||
Описание задачи:
|
||||
{raw_text}
|
||||
|
||||
Контекст репозитория:
|
||||
{repo_context}
|
||||
""".strip()
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
clean = strip_markdown_json(content)
|
||||
|
||||
try:
|
||||
return json.loads(clean)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"LLM returned invalid JSON: {content[:1000]}") from exc
|
||||
|
||||
|
||||
def taiga_auth() -> str:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
username = os.getenv("TAIGA_USERNAME")
|
||||
password = os.getenv("TAIGA_PASSWORD")
|
||||
|
||||
if not base_url or not username or not password:
|
||||
raise HTTPException(status_code=500, detail="Taiga env vars are missing")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/auth",
|
||||
json={
|
||||
"type": "normal",
|
||||
"username": username,
|
||||
"password": password,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["auth_token"]
|
||||
|
||||
|
||||
def taiga_headers(token: str):
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def format_story_description(task: dict, raw_text: str) -> str:
|
||||
lines = []
|
||||
|
||||
if task.get("description"):
|
||||
lines.append(task["description"])
|
||||
else:
|
||||
lines.append(raw_text)
|
||||
|
||||
if task.get("code_notes"):
|
||||
lines.append("")
|
||||
lines.append("## Заметки по коду")
|
||||
for item in task["code_notes"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("acceptance_criteria"):
|
||||
lines.append("")
|
||||
lines.append("## Acceptance criteria")
|
||||
for item in task["acceptance_criteria"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("questions"):
|
||||
lines.append("")
|
||||
lines.append("## Вопросы / уточнения")
|
||||
for item in task["questions"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("tags"):
|
||||
lines.append("")
|
||||
lines.append("## Теги")
|
||||
lines.append(", ".join(task["tags"]))
|
||||
|
||||
lines.append("")
|
||||
lines.append("## Исходное описание")
|
||||
lines.append(raw_text)
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def create_userstory(token: str, project_id: int, task: dict, raw_text: str) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/userstories",
|
||||
headers=taiga_headers(token),
|
||||
json={
|
||||
"project": project_id,
|
||||
"subject": (task.get("title") or raw_text)[:500],
|
||||
"description": format_story_description(task, raw_text),
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def create_subtask(token: str, project_id: int, userstory_id: int, child: dict) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/tasks",
|
||||
headers=taiga_headers(token),
|
||||
json={
|
||||
"project": project_id,
|
||||
"user_story": userstory_id,
|
||||
"subject": (child.get("title") or "Подзадача")[:500],
|
||||
"description": child.get("description") or "",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"repos_dir": os.getenv("AGENT_REPOS_DIR", "/repos"),
|
||||
"state_dir": os.getenv("AGENT_STATE_DIR", "/state"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/projects")
|
||||
def projects():
|
||||
config = load_config()
|
||||
return config.get("projects", {})
|
||||
|
||||
|
||||
@app.post("/repos/sync")
|
||||
def sync_repos(req: SyncRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project:
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
selected = {req.project: projects[req.project]}
|
||||
else:
|
||||
selected = projects
|
||||
|
||||
results = {}
|
||||
for name, project in selected.items():
|
||||
results[name] = sync_one_project(project)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/tasks/from-code")
|
||||
def task_from_code(req: TaskFromCodeRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
repo_path = project.get("repo_path")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
sync_result = sync_one_project(project)
|
||||
|
||||
if not repo_path or not os.path.exists(repo_path):
|
||||
raise HTTPException(status_code=400, detail="Repo path does not exist after sync")
|
||||
|
||||
repo_context = collect_repo_context(repo_path, req.text)
|
||||
structured = aimtr_make_task(req.text, repo_context)
|
||||
|
||||
token = taiga_auth()
|
||||
story = create_userstory(token, int(taiga_project_id), structured, req.text)
|
||||
|
||||
subtasks = []
|
||||
for child in structured.get("children", []):
|
||||
if isinstance(child, dict):
|
||||
subtasks.append(create_subtask(token, int(taiga_project_id), story["id"], child))
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"sync": sync_result,
|
||||
"story": {
|
||||
"id": story["id"],
|
||||
"ref": story["ref"],
|
||||
"subject": story["subject"],
|
||||
},
|
||||
"subtasks": [
|
||||
{
|
||||
"id": task.get("id"),
|
||||
"ref": task.get("ref"),
|
||||
"subject": task.get("subject"),
|
||||
}
|
||||
for task in subtasks
|
||||
],
|
||||
"structured": structured,
|
||||
}
|
||||
class NextActionRequest(BaseModel):
|
||||
project: str
|
||||
minutes: int = 25
|
||||
energy: str = "normal"
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def compact_userstory(us: dict) -> dict:
|
||||
return {
|
||||
"id": us.get("id"),
|
||||
"ref": us.get("ref"),
|
||||
"subject": us.get("subject"),
|
||||
"status": (us.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": us.get("is_closed"),
|
||||
"assigned_to": (us.get("assigned_to_extra_info") or {}).get("username"),
|
||||
"created_date": us.get("created_date"),
|
||||
"modified_date": us.get("modified_date"),
|
||||
"description": (us.get("description") or "")[:1500],
|
||||
}
|
||||
|
||||
|
||||
def compact_task(task: dict) -> dict:
|
||||
return {
|
||||
"id": task.get("id"),
|
||||
"ref": task.get("ref"),
|
||||
"subject": task.get("subject"),
|
||||
"status": (task.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": task.get("is_closed"),
|
||||
"assigned_to": (task.get("assigned_to_extra_info") or {}).get("username"),
|
||||
"user_story": task.get("user_story"),
|
||||
"created_date": task.get("created_date"),
|
||||
"modified_date": task.get("modified_date"),
|
||||
"description": (task.get("description") or "")[:1200],
|
||||
}
|
||||
|
||||
|
||||
def taiga_get_backlog(token: str, project_id: int) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
stories_resp = requests.get(
|
||||
f"{base_url}/api/v1/userstories",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"order_by": "-modified_date",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
stories_resp.raise_for_status()
|
||||
|
||||
tasks_resp = requests.get(
|
||||
f"{base_url}/api/v1/tasks",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"order_by": "-modified_date",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
tasks_resp.raise_for_status()
|
||||
|
||||
stories = stories_resp.json()
|
||||
tasks = tasks_resp.json()
|
||||
|
||||
open_stories = [compact_userstory(x) for x in stories if not x.get("is_closed")]
|
||||
open_tasks = [compact_task(x) for x in tasks if not x.get("is_closed")]
|
||||
|
||||
return {
|
||||
"userstories": open_stories[:50],
|
||||
"tasks": open_tasks[:80],
|
||||
}
|
||||
|
||||
|
||||
def aimtr_next_action(project_name: str, minutes: int, energy: str, notes: str | None, backlog: dict) -> dict:
|
||||
base_url = os.getenv("AIMTR_BASE_URL", "").rstrip("/")
|
||||
api_key = os.getenv("AIMTR_API_KEY")
|
||||
model = os.getenv("AIMTR_MODEL", "claude-haiku-4.5")
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(status_code=500, detail="AIMTR_BASE_URL or AIMTR_API_KEY is missing")
|
||||
|
||||
system_prompt = """
|
||||
Ты техлид и персональный фокус-ассистент.
|
||||
Твоя задача — выбрать ОДНО лучшее действие на ближайший pomodoro.
|
||||
|
||||
Отвечай только валидным JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"recommended": {
|
||||
"kind": "task|story|meta",
|
||||
"ref": 123,
|
||||
"title": "что делать",
|
||||
"why_now": "почему именно это сейчас",
|
||||
"expected_result": "что должно быть готово к концу pomodoro",
|
||||
"risk": "главный риск или блокер"
|
||||
},
|
||||
"pomodoro_plan": [
|
||||
"шаг 1",
|
||||
"шаг 2",
|
||||
"шаг 3"
|
||||
],
|
||||
"definition_of_done": [
|
||||
"критерий готовности 1",
|
||||
"критерий готовности 2"
|
||||
],
|
||||
"skip_reasoning": [
|
||||
"почему не выбраны другие важные задачи"
|
||||
],
|
||||
"questions": [
|
||||
"короткий вопрос, если без него нельзя начать"
|
||||
]
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Выбирай задачу, которую реально сдвинуть за заданное количество минут.
|
||||
- Предпочитай маленький понятный next step, а не огромную важную задачу.
|
||||
- Если есть недавно созданные подзадачи без прогресса — они хорошие кандидаты.
|
||||
- Не выбирай закрытые задачи.
|
||||
- Если данных мало, всё равно предложи лучший следующий шаг.
|
||||
- Пиши на русском.
|
||||
""".strip()
|
||||
|
||||
payload = {
|
||||
"project": project_name,
|
||||
"minutes": minutes,
|
||||
"energy": energy,
|
||||
"notes": notes,
|
||||
"backlog": backlog,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(payload, ensure_ascii=False),
|
||||
},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
clean = strip_markdown_json(content)
|
||||
|
||||
try:
|
||||
return json.loads(clean)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"LLM returned invalid JSON: {content[:1000]}") from exc
|
||||
|
||||
|
||||
@app.post("/next-action")
|
||||
def next_action(req: NextActionRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
token = taiga_auth()
|
||||
backlog = taiga_get_backlog(token, int(taiga_project_id))
|
||||
|
||||
recommendation = aimtr_next_action(
|
||||
project_name=req.project,
|
||||
minutes=req.minutes,
|
||||
energy=req.energy,
|
||||
notes=req.notes,
|
||||
backlog=backlog,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"minutes": req.minutes,
|
||||
"energy": req.energy,
|
||||
"backlog_counts": {
|
||||
"userstories": len(backlog["userstories"]),
|
||||
"tasks": len(backlog["tasks"]),
|
||||
},
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
class PomodoroFinishRequest(BaseModel):
|
||||
project: str
|
||||
task_ref: int
|
||||
minutes: int = 25
|
||||
result: str
|
||||
done: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def taiga_find_task_by_ref(token: str, project_id: int, task_ref: int) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/tasks/by_ref",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"ref": task_ref,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def taiga_get_closed_task_status(token: str, project_id: int) -> int | None:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/task-statuses",
|
||||
headers=taiga_headers(token),
|
||||
params={"project": project_id},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
statuses = response.json()
|
||||
for status in statuses:
|
||||
if status.get("is_closed"):
|
||||
return status.get("id")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_pomodoro_comment(req: PomodoroFinishRequest) -> str:
|
||||
lines = [
|
||||
f"🍅 Pomodoro report: {req.minutes} min",
|
||||
"",
|
||||
"Result:",
|
||||
req.result.strip(),
|
||||
]
|
||||
|
||||
if req.notes:
|
||||
lines.extend(["", "Notes:", req.notes.strip()])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
f"Marked done: {'yes' if req.done else 'no'}",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def taiga_update_task_comment(
|
||||
token: str,
|
||||
task: dict,
|
||||
comment: str,
|
||||
close: bool,
|
||||
closed_status_id: int | None,
|
||||
) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
payload = {
|
||||
"version": task.get("version"),
|
||||
"comment": comment,
|
||||
}
|
||||
|
||||
if close and closed_status_id:
|
||||
payload["status"] = closed_status_id
|
||||
|
||||
response = requests.patch(
|
||||
f"{base_url}/api/v1/tasks/{task['id']}",
|
||||
headers=taiga_headers(token),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@app.post("/pomodoro/finish")
|
||||
def pomodoro_finish(req: PomodoroFinishRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
token = taiga_auth()
|
||||
|
||||
task = taiga_find_task_by_ref(token, int(taiga_project_id), req.task_ref)
|
||||
comment = format_pomodoro_comment(req)
|
||||
|
||||
closed_status_id = None
|
||||
if req.done:
|
||||
closed_status_id = taiga_get_closed_task_status(token, int(taiga_project_id))
|
||||
|
||||
updated_task = taiga_update_task_comment(
|
||||
token=token,
|
||||
task=task,
|
||||
comment=comment,
|
||||
close=req.done,
|
||||
closed_status_id=closed_status_id,
|
||||
)
|
||||
|
||||
backlog = taiga_get_backlog(token, int(taiga_project_id))
|
||||
recommendation = aimtr_next_action(
|
||||
project_name=req.project,
|
||||
minutes=req.minutes,
|
||||
energy="normal",
|
||||
notes=f"Just finished task #{req.task_ref}. Result: {req.result}",
|
||||
backlog=backlog,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"task": {
|
||||
"id": updated_task.get("id"),
|
||||
"ref": updated_task.get("ref"),
|
||||
"subject": updated_task.get("subject"),
|
||||
"status": (updated_task.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": updated_task.get("is_closed"),
|
||||
},
|
||||
"comment_added": True,
|
||||
"closed": bool(req.done and closed_status_id),
|
||||
"next_recommendation": recommendation,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
project-agent:
|
||||
build: .
|
||||
container_name: project-agent
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
AGENT_REPOS_DIR: /repos
|
||||
AGENT_STATE_DIR: /state
|
||||
AGENT_LOG_DIR: /logs
|
||||
AGENT_ALLOW_GIT_PUSH: "false"
|
||||
AGENT_ALLOW_DELETE: "false"
|
||||
AGENT_ALLOW_JENKINS_BUILD: "false"
|
||||
|
||||
volumes:
|
||||
- ./app:/app
|
||||
- ./repos:/repos
|
||||
- ./state:/state
|
||||
- ./logs:/logs
|
||||
- ./ssh:/home/agent/.ssh
|
||||
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ai-internal
|
||||
cap_drop:
|
||||
- NET_RAW
|
||||
- NET_ADMIN
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
user: "1000:1000"
|
||||
working_dir: /app
|
||||
|
||||
command: ["uvicorn", "project_agent.server:app", "--host", "0.0.0.0", "--port", "8787"]
|
||||
networks:
|
||||
ai-internal:
|
||||
external: true
|
||||
@@ -0,0 +1,7 @@
|
||||
requests
|
||||
python-dotenv
|
||||
pyyaml
|
||||
fastapi
|
||||
uvicorn
|
||||
pydantic
|
||||
gitpython
|
||||
@@ -0,0 +1,16 @@
|
||||
# Project Agent
|
||||
|
||||
Use this skill when the user asks to create a development task, formalize a feature request, analyze a repository, or create a Taiga task with code context.
|
||||
|
||||
The project-agent service is available at:
|
||||
|
||||
http://host.docker.internal:8787
|
||||
|
||||
## Create code-aware Taiga task
|
||||
|
||||
When the user asks to create a task with repository/code context, call:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://host.docker.internal:8787/tasks/from-code \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"PROJECT_NAME","text":"USER_TASK_TEXT"}'
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
Host host.docker.internal
|
||||
HostName host.docker.internal
|
||||
Port 222
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
StrictHostKeyChecking no
|
||||
|
||||
Host gitea
|
||||
HostName gitea
|
||||
Port 22
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
StrictHostKeyChecking no
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACADNVk72R6uhUnllRo0D3PYYrD84CMNXFDY8yETRDP5xQAAAJhVkNALVZDQ
|
||||
CwAAAAtzc2gtZWQyNTUxOQAAACADNVk72R6uhUnllRo0D3PYYrD84CMNXFDY8yETRDP5xQ
|
||||
AAAEDrqlA/TdO3vweJ5COFh7FGlDcZL0mfGAiZf9vEhg969wM1WTvZHq6FSeWVGjQPc9hi
|
||||
sPzgIw1cUNjzIRNEM/nFAAAAE3Byb2plY3QtYWdlbnRAZ2l0ZWEBAg==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAM1WTvZHq6FSeWVGjQPc9hisPzgIw1cUNjzIRNEM/nF project-agent@gitea
|
||||
@@ -0,0 +1,3 @@
|
||||
|1|thCXdX9Xl9BI3xww0UbCvIYKxJc=|UEPlDdMatUmQsSH0hgl5qzYb2nc= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA19YYuBqcAX+odNfU/Gp22CPcNe7zjhe2wLEAUkqTKA
|
||||
|1|aP/H9yZzXxxt6rVr50XYa4KqvTc=|ntvwoH5lbNxqZyZO45D7qT2REiY= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGqQf4V5bz092+9kVRY9lANARXOH3cJ67tC+0e5JB25shRP/dS6iHZpDFq9WLaADEjuo7IXKdE+1hmHpaC4ypyVhzez3+IuLn+FPV96Yk4COyEP1Abz+t92PGn4xOKJ+vDNndaq4bcC6GOCMEyte76z9aVDkBOs2VKTL6e+jqgqT94f3iJexWRG/A6mju106UatDMiiou973Vru0gaFf+kwK99NpbATkDVcJFaxi5j6oPDpCkTdMKqfv54nyQzpS86/qMLi6MLKJeWoz2S+45GGHQTjA1qpI6RHw+FFAJJvtT22RtsPiXSISMQSdPCpc35MNs5DpHwZ6D6BFylh511MikWvWEHIOaoDf3C68vcxtSJu4C3F/XH1nD9iAjlm8Lc3yTHmWGmoCjFBmZIP0GmBa4yMuMWwb1TXKht/k0cPg1VESTxQq8LDx+npoxB7ghXyGknosfqGz/gRg3CMnitbXTQmPT+xgog7NhHKXrNQDNf0yKW4FkKJvx4oCw6AXc=
|
||||
|1|iDqBv+T3WmkJgy++AmaIio63lao=|P1sKU3ksjqHiMCukvK26K5YQQgc= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBI7iYtigX+QKUwYnRg6uAUz9EB/0UQ2eic/Mo9yS/DFDyiWycoBmjsz7qTMwAtbnji/1vVq5US2362SXxl2skw0=
|
||||
@@ -0,0 +1 @@
|
||||
|1|thCXdX9Xl9BI3xww0UbCvIYKxJc=|UEPlDdMatUmQsSH0hgl5qzYb2nc= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA19YYuBqcAX+odNfU/Gp22CPcNe7zjhe2wLEAUkqTKA
|
||||
Reference in New Issue
Block a user