From 5466d14040ea04f65a97c283c9c389e08e8a862f Mon Sep 17 00:00:00 2001 From: Grigo Date: Mon, 18 May 2026 20:35:01 +0000 Subject: [PATCH] first commit --- .env | 14 + .gitignore | 1 + Dockerfile | 22 + README.md | 0 app/config.yml | 36 + app/project_agent/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 127 bytes .../__pycache__/server.cpython-312.pyc | Bin 0 -> 30138 bytes app/project_agent/server.py | 773 ++++++++++++++++++ docker-compose.yml | 46 ++ requirements.txt | 7 + skills/project-agent/SKILL.md | 16 + ssh/config | 13 + ssh/id_ed25519 | 7 + ssh/id_ed25519.pub | 1 + ssh/known_hosts | 3 + ssh/known_hosts.old | 1 + 17 files changed, 940 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/config.yml create mode 100644 app/project_agent/__init__.py create mode 100644 app/project_agent/__pycache__/__init__.cpython-312.pyc create mode 100644 app/project_agent/__pycache__/server.cpython-312.pyc create mode 100644 app/project_agent/server.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 skills/project-agent/SKILL.md create mode 100644 ssh/config create mode 100644 ssh/id_ed25519 create mode 100644 ssh/id_ed25519.pub create mode 100644 ssh/known_hosts create mode 100644 ssh/known_hosts.old diff --git a/.env b/.env new file mode 100644 index 0000000..36f3f24 --- /dev/null +++ b/.env @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0c7ae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +repos/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1919a1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/config.yml b/app/config.yml new file mode 100644 index 0000000..f6f51b0 --- /dev/null +++ b/app/config.yml @@ -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: \ No newline at end of file diff --git a/app/project_agent/__init__.py b/app/project_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/project_agent/__pycache__/__init__.cpython-312.pyc b/app/project_agent/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6afd073901b3380530b55e3124b87102f49126fb GIT binary patch literal 127 zcmX@j%ge<81U09)vOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!lG9HtD9|q`%Fjwo zE{RV}Pt7aQkB`sH%PfhH*DI*J#bJ}1pHiBWYFESxRL%&*#URE;M1& literal 0 HcmV?d00001 diff --git a/app/project_agent/__pycache__/server.cpython-312.pyc b/app/project_agent/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0185ef582b15d7fdc1ac7eca395991c370b1368b GIT binary patch literal 30138 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 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, + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6b2ce1a --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..533d427 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +requests +python-dotenv +pyyaml +fastapi +uvicorn +pydantic +gitpython diff --git a/skills/project-agent/SKILL.md b/skills/project-agent/SKILL.md new file mode 100644 index 0000000..78468eb --- /dev/null +++ b/skills/project-agent/SKILL.md @@ -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"}' diff --git a/ssh/config b/ssh/config new file mode 100644 index 0000000..4184b4c --- /dev/null +++ b/ssh/config @@ -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 diff --git a/ssh/id_ed25519 b/ssh/id_ed25519 new file mode 100644 index 0000000..80c27ae --- /dev/null +++ b/ssh/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACADNVk72R6uhUnllRo0D3PYYrD84CMNXFDY8yETRDP5xQAAAJhVkNALVZDQ +CwAAAAtzc2gtZWQyNTUxOQAAACADNVk72R6uhUnllRo0D3PYYrD84CMNXFDY8yETRDP5xQ +AAAEDrqlA/TdO3vweJ5COFh7FGlDcZL0mfGAiZf9vEhg969wM1WTvZHq6FSeWVGjQPc9hi +sPzgIw1cUNjzIRNEM/nFAAAAE3Byb2plY3QtYWdlbnRAZ2l0ZWEBAg== +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh/id_ed25519.pub b/ssh/id_ed25519.pub new file mode 100644 index 0000000..3fc561b --- /dev/null +++ b/ssh/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAM1WTvZHq6FSeWVGjQPc9hisPzgIw1cUNjzIRNEM/nF project-agent@gitea diff --git a/ssh/known_hosts b/ssh/known_hosts new file mode 100644 index 0000000..8c204c3 --- /dev/null +++ b/ssh/known_hosts @@ -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= diff --git a/ssh/known_hosts.old b/ssh/known_hosts.old new file mode 100644 index 0000000..3501de1 --- /dev/null +++ b/ssh/known_hosts.old @@ -0,0 +1 @@ +|1|thCXdX9Xl9BI3xww0UbCvIYKxJc=|UEPlDdMatUmQsSH0hgl5qzYb2nc= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA19YYuBqcAX+odNfU/Gp22CPcNe7zjhe2wLEAUkqTKA