From 1f83dcb57445370ff29471676c5c9abae22754d9 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 9 Jun 2026 12:47:13 +0300 Subject: [PATCH] Taiga integration --- .env.example | 22 +- README.md | 65 +++- backend/app/api/routes/__init__.py | 4 +- backend/app/api/routes/projects.py | 76 ++++ backend/app/api/routes/webhooks.py | 94 +++++ backend/app/character/card.py | 6 +- backend/app/chat/notices.py | 54 +++ backend/app/chat/service.py | 7 +- backend/app/config.py | 21 ++ backend/app/db/models.py | 42 +++ backend/app/integrations/__init__.py | 0 backend/app/integrations/gitea.py | 54 +++ backend/app/integrations/taiga.py | 190 ++++++++++ backend/app/projects/__init__.py | 3 + backend/app/projects/commit_parser.py | 43 +++ backend/app/projects/context.py | 109 ++++++ backend/app/projects/service.py | 393 +++++++++++++++++++++ backend/app/projects/structuring.py | 104 ++++++ backend/app/tools/registry.py | 98 ++++- backend/requirements.txt | 1 + docker-compose.yml | 2 + frontend/src/App.tsx | 3 + frontend/src/components/PomodoroWidget.css | 45 ++- frontend/src/components/PomodoroWidget.tsx | 41 +-- frontend/src/context/PomodoroContext.tsx | 54 +++ frontend/src/hooks/usePomodoro.ts | 27 -- frontend/src/pages/Chat.tsx | 11 +- frontend/src/pages/Pomodoro.tsx | 60 ++-- frontend/src/utils/pomodoro.ts | 27 ++ frontend/tsconfig.tsbuildinfo | 2 +- 30 files changed, 1543 insertions(+), 115 deletions(-) create mode 100644 backend/app/api/routes/projects.py create mode 100644 backend/app/api/routes/webhooks.py create mode 100644 backend/app/integrations/__init__.py create mode 100644 backend/app/integrations/gitea.py create mode 100644 backend/app/integrations/taiga.py create mode 100644 backend/app/projects/__init__.py create mode 100644 backend/app/projects/commit_parser.py create mode 100644 backend/app/projects/context.py create mode 100644 backend/app/projects/service.py create mode 100644 backend/app/projects/structuring.py create mode 100644 frontend/src/context/PomodoroContext.tsx delete mode 100644 frontend/src/hooks/usePomodoro.ts diff --git a/.env.example b/.env.example index 2a917d5..1048574 100644 --- a/.env.example +++ b/.env.example @@ -15,16 +15,24 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # App DATABASE_URL=sqlite:///./data/assistant.db -# Add your server URL with FRONTEND_PORT, e.g. http://grigosserver:3080 CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 SYSTEM_PROMPT_PATH=./prompts/assistant.md -# External services (phase 2 — homelab integrations) -TAIGA_BASE_URL=http://taiga:9000 -TAIGA_PORT=9000 -GITEA_BASE_URL=http://gitea:3000 -GITEA_PORT=3000 -GITEA_SSH_PORT=222 +# Taiga (on host :9000, nginx → taiga.grigowashere.ru) +TAIGA_BASE_URL=http://host.docker.internal:9000 +TAIGA_USERNAME=your_taiga_user +TAIGA_PASSWORD=your_taiga_password +TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru + +# Gitea (on host :3000, nginx → git.grigowashere.ru) +GITEA_BASE_URL=http://host.docker.internal:3000 +GITEA_TOKEN=your_gitea_api_token +GITEA_PUBLIC_URL=https://git.grigowashere.ru +GITEA_WEBHOOK_SECRET=generate_a_random_secret + +# Gitea webhook URL (configure in repo settings): +# http://127.0.0.1:8080/api/v1/webhooks/gitea + REPOS_DIR=/data/repos # Vector DB (phase 3) diff --git a/README.md b/README.md index a35063e..a2ae965 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,70 @@ Vite dev-server: http://localhost:5173 (проксирует `/api` на backend | POST | `/api/v1/pomodoro/resume` | Продолжить | | POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` | | GET | `/api/v1/pomodoro/history` | История сессий | +| GET | `/api/v1/projects` | Проекты Taiga + привязка Gitea | +| POST | `/api/v1/projects/sync-taiga` | Синхронизировать проекты из Taiga | +| PUT | `/api/v1/projects/{slug}/gitea` | Привязать Gitea repo | +| POST | `/api/v1/work-items` | Создать фичу/баг → Taiga + Gitea | +| GET | `/api/v1/work-items` | Список work items | +| POST | `/api/v1/webhooks/gitea` | Webhook для автозакрытия по push | + +## Taiga + Gitea (фаза 2) + +Taiga и Gitea работают **на хосте** (не в Docker): +- Taiga: `127.0.0.1:9000` → `taiga.grigowashere.ru` +- Gitea: `127.0.0.1:3000` → `git.grigowashere.ru` + +Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`). + +### Настройка `.env` + +```env +TAIGA_BASE_URL=http://host.docker.internal:9000 +TAIGA_USERNAME=... +TAIGA_PASSWORD=... +TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru + +GITEA_BASE_URL=http://host.docker.internal:3000 +GITEA_TOKEN=... # Settings → Applications → Generate Token +GITEA_PUBLIC_URL=https://git.grigowashere.ru +GITEA_WEBHOOK_SECRET=... # произвольная строка +``` + +### Первый запуск + +```bash +# 1. Синхронизировать проекты Taiga (ID подтянутся автоматически) +curl -X POST http://localhost:8080/api/v1/projects/sync-taiga + +# 2. Привязать Gitea repo к проекту Taiga +curl -X PUT http://localhost:8080/api/v1/projects/home-assistant/gitea \ + -H "Content-Type: application/json" \ + -d '{"gitea_owner":"Grigo","gitea_repo":"Home_assistant","default_branch":"main"}' +``` + +### Gitea webhook + +В репозитории: **Settings → Webhooks → Add Webhook**: + +- URL: `http://127.0.0.1:8080/api/v1/webhooks/gitea` +- Content type: `application/json` +- Secret: значение `GITEA_WEBHOOK_SECRET` +- Events: **Push** + +### Автозакрытие по коммиту + +В сообщении коммита: + +``` +fix: кнопка сохранения +Closes gitea #12, taiga #45 +``` + +Закроются Gitea issue #12 и Taiga story #45 (если только один ref — второй найдётся по связи в БД). + +### Чат + +«Заведи баг: кнопка не сохраняет настройки» → `create_work_item` → Taiga story + Gitea issue + ветка `feature/45-...`. ## Структура проекта @@ -98,7 +162,6 @@ data/ SQLite БД (создаётся автоматически) ## Следующие фазы -- Интеграция Taiga + Gitea (project-agent внутри проекта) - RAG с Qdrant для документов - Проактивные чаты по расписанию - Фитнес-трекер diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 6c90ead..1bd22b9 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,9 +1,11 @@ from fastapi import APIRouter -from app.api.routes import character, chat, health, pomodoro +from app.api.routes import character, chat, health, pomodoro, projects, webhooks api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router, tags=["health"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(character.router, tags=["character"]) +api_router.include_router(projects.router, tags=["projects"]) +api_router.include_router(webhooks.router, tags=["webhooks"]) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py new file mode 100644 index 0000000..e4eb9a6 --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,76 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.db.base import get_db +from app.projects.service import ProjectService + +router = APIRouter() + + +class GiteaBinding(BaseModel): + gitea_owner: str = Field(min_length=1) + gitea_repo: str = Field(min_length=1) + default_branch: str = "main" + + +class WorkItemCreate(BaseModel): + text: str = Field(min_length=1) + project_slug: str | None = None + + +@router.get("/projects") +def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: + return ProjectService(db).list_projects() + + +@router.post("/projects/sync-taiga") +def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]: + try: + return ProjectService(db).sync_taiga_projects() + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/projects/{taiga_slug}/gitea") +def bind_gitea( + taiga_slug: str, + payload: GiteaBinding, + db: Session = Depends(get_db), +) -> dict[str, Any]: + try: + return ProjectService(db).bind_gitea( + taiga_slug, + payload.gitea_owner, + payload.gitea_repo, + payload.default_branch, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/work-items") +async def create_work_item( + payload: WorkItemCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + try: + return await ProjectService(db).create_work_item( + payload.text, + project_slug=payload.project_slug, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/work-items") +def list_work_items( + limit: int = 30, + status: str | None = None, + db: Session = Depends(get_db), +) -> list[dict[str, Any]]: + return ProjectService(db).list_work_items(limit=limit, status=status) diff --git a/backend/app/api/routes/webhooks.py b/backend/app/api/routes/webhooks.py new file mode 100644 index 0000000..537ef09 --- /dev/null +++ b/backend/app/api/routes/webhooks.py @@ -0,0 +1,94 @@ +import hashlib +import hmac +import json +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db.base import SessionLocal, get_db +from app.db.models import ChatSession, Message, ProjectBinding +from app.projects.service import ProjectService + +router = APIRouter() + + +def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool: + if not secret: + return True + if not signature: + return False + if signature.startswith("sha256="): + signature = signature[7:] + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None: + if not results: + return + db = SessionLocal() + try: + session = db.scalar( + select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) + ) + if not session: + session = ChatSession(title="Git") + db.add(session) + db.commit() + db.refresh(session) + + lines = [f"🔀 **Push** `{owner}/{repo}`"] + for item in results: + if "closed" in item: + lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}") + elif "error" in item: + lines.append(f"- ошибка: {item['error']}") + + db.add(Message(session_id=session.id, role="notice", content="\n".join(lines))) + db.commit() + finally: + db.close() + + +@router.post("/webhooks/gitea") +async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]: + body = await request.body() + settings = get_settings() + signature = request.headers.get("X-Gitea-Signature") + + if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + payload = json.loads(body) + if payload.get("secret") and settings.gitea_webhook_secret: + if payload.get("secret") != settings.gitea_webhook_secret: + raise HTTPException(status_code=401, detail="Invalid webhook secret") + + event = request.headers.get("X-Gitea-Event", "") + if event != "push": + return {"ok": True, "skipped": event} + + repo = payload.get("repository", {}) + owner = repo.get("owner", {}).get("login", "") + repo_name = repo.get("name", "") + if not owner or not repo_name: + raise HTTPException(status_code=400, detail="Missing repository info") + + binding = db.scalar( + select(ProjectBinding).where( + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo_name, + ) + ) + if not binding: + return {"ok": True, "skipped": "unknown repo"} + + commits = payload.get("commits") or [] + service = ProjectService(db) + results = service.process_push(owner, repo_name, commits) + _post_close_notice(results, owner, repo_name) + + return {"ok": True, "results": results} diff --git a/backend/app/character/card.py b/backend/app/character/card.py index ba47142..ee3d81c 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -6,9 +6,11 @@ TOOLS_INSTRUCTIONS = """ - Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. - Никогда не выдумывай статус таймера или список задач. - После вызова инструмента кратко объясни результат пользователю по-человечески. -- Инструменты: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, +- Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. -- reset_pomodoro_cycle — только когда пользователь явно просит сбросить цикл. +- Задачи: sync_taiga_projects, list_taiga_projects, create_work_item, list_work_items. +- create_work_item — при «заведи баг/фичу», «добавь в таигу»; передай полный текст пользователя. +- Список проектов и открытых задач уже в контексте — не выдумывай, при необходимости уточни tool-вызовом. """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 197e1cd..4bc9d71 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -67,9 +67,63 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: if tool_name == "get_pomodoro_history": return _format_history_notice(data) + if tool_name == "create_work_item": + return _format_work_item_notice(data) + + if tool_name == "list_work_items": + return _format_work_items_list_notice(data) + + if tool_name == "sync_taiga_projects": + return f"📋 Синхронизировано проектов Taiga: **{len(data)}**" + + if tool_name == "list_taiga_projects": + if not isinstance(data, list) or not data: + return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects." + lines = ["📋 **Проекты:**"] + for p in data: + gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—" + lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}") + return "\n".join(lines) + return None +def _format_work_item_notice(data: dict[str, Any]) -> str | None: + if data.get("error"): + return f"📋 {data['error']}" + if not data.get("ok"): + return None + taiga = data.get("taiga", {}) + gitea = data.get("gitea", {}) + lines = [ + "📋 **Создано:**", + f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}", + f"- URL: {taiga.get('url')}", + ] + if gitea.get("url"): + lines.append(f"- Gitea: {gitea.get('url')}") + if data.get("branch"): + lines.append(f"- Ветка: `{data['branch']}`") + subtasks = data.get("subtasks") or [] + if subtasks: + lines.append("**Подзадачи:**") + for t in subtasks: + lines.append(f"- #{t.get('ref')} {t.get('subject')}") + return "\n".join(lines) + + +def _format_work_items_list_notice(data: Any) -> str | None: + if not isinstance(data, list) or not data: + return "📋 Work items не найдены." + lines = ["📋 **Work items:**"] + for item in data[:15]: + lines.append( + f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} " + f"({item.get('taiga_slug')})" + ) + return "\n".join(lines) + + def _format_status_notice(data: dict[str, Any]) -> str: status = data.get("status", "idle") phase = data.get("phase", PHASE_WORK) diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 19bcc59..02a4bad 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from app.character.service import CharacterService from app.chat.notices import format_pomodoro_context, format_pomodoro_notice +from app.projects.context import format_projects_context, get_projects_snapshot from app.db.models import ChatSession, Message from app.llm.client import LLMClient from app.pomodoro.service import PomodoroService @@ -45,9 +46,11 @@ class ChatService: def _build_system_prompt(self) -> str: status = PomodoroService(self.db).get_status() + projects_snapshot = get_projects_snapshot(self.db) return ( f"{self.character.get_system_prompt()}\n\n" - f"{format_pomodoro_context(status)}" + f"{format_pomodoro_context(status)}\n\n" + f"{format_projects_context(projects_snapshot)}" ) def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]: @@ -129,7 +132,7 @@ class ChatService: for tool_call in tool_calls: fn = tool_call["function"] args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) - result = execute_tool(self.db, fn["name"], args) + result = await execute_tool(self.db, fn["name"], args) tool_message = { "role": "tool", "tool_call_id": tool_call["id"], diff --git a/backend/app/config.py b/backend/app/config.py index 49e8399..d9ee859 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,10 +22,31 @@ class Settings(BaseSettings): cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" system_prompt_path: str = "./prompts/assistant.md" + # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container + taiga_base_url: str = "http://host.docker.internal:9000" + taiga_username: str = "" + taiga_password: str = "" + taiga_public_url: str = "https://taiga.grigowashere.ru" + + gitea_base_url: str = "http://host.docker.internal:3000" + gitea_token: str = "" + gitea_public_url: str = "https://git.grigowashere.ru" + gitea_webhook_secret: str = "" + + repos_dir: str = "/data/repos" + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + @property + def taiga_configured(self) -> bool: + return bool(self.taiga_username and self.taiga_password) + + @property + def gitea_configured(self) -> bool: + return bool(self.gitea_token) + def load_system_prompt(self) -> str: path = Path(self.system_prompt_path) if path.is_file(): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index bb67960..b52ce9e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -68,3 +68,45 @@ class PomodoroSession(Base): elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class TaigaProject(Base): + __tablename__ = "taiga_projects" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) + synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class ProjectBinding(Base): + __tablename__ = "project_bindings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True) + gitea_owner: Mapped[str] = mapped_column(String(255), default="") + gitea_repo: Mapped[str] = mapped_column(String(255), default="") + default_branch: Mapped[str] = mapped_column(String(64), default="main") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class WorkItem(Base): + __tablename__ = "work_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + taiga_slug: Mapped[str] = mapped_column(String(255), index=True) + taiga_project_id: Mapped[int] = mapped_column(Integer) + taiga_story_id: Mapped[int] = mapped_column(Integer) + taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True) + gitea_owner: Mapped[str] = mapped_column(String(255), default="") + gitea_repo: Mapped[str] = mapped_column(String(255), default="") + gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + suggested_branch: Mapped[str] = mapped_column(String(255), default="") + raw_text: Mapped[str] = mapped_column(Text, default="") + title: Mapped[str] = mapped_column(String(500), default="") + status: Mapped[str] = mapped_column(String(32), default="open") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/gitea.py b/backend/app/integrations/gitea.py new file mode 100644 index 0000000..d884478 --- /dev/null +++ b/backend/app/integrations/gitea.py @@ -0,0 +1,54 @@ +from typing import Any + +import httpx + +from app.config import get_settings + + +class GiteaClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = settings.gitea_base_url.rstrip("/") + self.public_url = settings.gitea_public_url.rstrip("/") + self.token = settings.gitea_token + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self.base_url, + timeout=30.0, + headers={ + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + }, + ) + + def create_issue( + self, + owner: str, + repo: str, + title: str, + body: str, + labels: list[str] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"title": title, "body": body} + if labels: + payload["labels"] = labels + with self._client() as client: + response = client.post( + f"/api/v1/repos/{owner}/{repo}/issues", + json=payload, + ) + response.raise_for_status() + return response.json() + + def close_issue(self, owner: str, repo: str, issue_number: int) -> dict[str, Any]: + with self._client() as client: + response = client.patch( + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}", + json={"state": "closed"}, + ) + response.raise_for_status() + return response.json() + + def issue_url(self, owner: str, repo: str, issue_number: int) -> str: + return f"{self.public_url}/{owner}/{repo}/issues/{issue_number}" diff --git a/backend/app/integrations/taiga.py b/backend/app/integrations/taiga.py new file mode 100644 index 0000000..d3b2b06 --- /dev/null +++ b/backend/app/integrations/taiga.py @@ -0,0 +1,190 @@ +from typing import Any + +import httpx + +from app.config import get_settings + + +class TaigaClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = settings.taiga_base_url.rstrip("/") + self.public_url = settings.taiga_public_url.rstrip("/") + self.username = settings.taiga_username + self.password = settings.taiga_password + self._token: str | None = None + + def _client(self) -> httpx.Client: + return httpx.Client(base_url=self.base_url, timeout=30.0) + + def auth(self) -> str: + if self._token: + return self._token + with self._client() as client: + response = client.post( + "/api/v1/auth", + json={ + "type": "normal", + "username": self.username, + "password": self.password, + }, + ) + response.raise_for_status() + self._token = response.json()["auth_token"] + return self._token + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.auth()}", + "Content-Type": "application/json", + } + + def list_projects(self) -> list[dict[str, Any]]: + with self._client() as client: + response = client.get("/api/v1/projects", headers=self._headers()) + response.raise_for_status() + return response.json() + + def list_open_userstories(self, project_id: int, limit: int = 8) -> list[dict[str, Any]]: + with self._client() as client: + response = client.get( + "/api/v1/userstories", + params={"project": project_id}, + headers=self._headers(), + ) + response.raise_for_status() + open_stories = [s for s in response.json() if not s.get("is_closed")] + return open_stories[:limit] + + def list_open_tasks(self, project_id: int, limit: int = 8) -> list[dict[str, Any]]: + with self._client() as client: + response = client.get( + "/api/v1/tasks", + params={"project": project_id}, + headers=self._headers(), + ) + response.raise_for_status() + open_tasks = [t for t in response.json() if not t.get("is_closed")] + return open_tasks[:limit] + + def get_closed_status_id(self, project_id: int, *, for_task: bool = False) -> int | None: + endpoint = "/api/v1/task-statuses" if for_task else "/api/v1/userstory-statuses" + with self._client() as client: + response = client.get( + endpoint, + params={"project": project_id}, + headers=self._headers(), + ) + response.raise_for_status() + items = response.json() + for status in items: + if status.get("is_closed") or status.get("name", "").lower() in ( + "done", + "closed", + "завершено", + "закрыто", + ): + return status["id"] + return items[-1]["id"] if items else None + + def create_userstory( + self, + project_id: int, + subject: str, + description: str, + tags: list[str] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "project": project_id, + "subject": subject[:500], + "description": description, + } + if tags: + payload["tags"] = tags + with self._client() as client: + response = client.post( + "/api/v1/userstories", + headers=self._headers(), + json=payload, + ) + response.raise_for_status() + return response.json() + + def create_task( + self, + project_id: int, + user_story_id: int, + subject: str, + description: str = "", + ) -> dict[str, Any]: + with self._client() as client: + response = client.post( + "/api/v1/tasks", + headers=self._headers(), + json={ + "project": project_id, + "user_story": user_story_id, + "subject": subject[:500], + "description": description, + }, + ) + response.raise_for_status() + return response.json() + + def close_userstory(self, story_id: int, project_id: int) -> dict[str, Any]: + status_id = self.get_closed_status_id(project_id, for_task=False) + payload: dict[str, Any] = {"version": self._get_version("userstories", story_id)} + if status_id: + payload["status"] = status_id + else: + payload["is_closed"] = True + with self._client() as client: + response = client.patch( + f"/api/v1/userstories/{story_id}", + headers=self._headers(), + json=payload, + ) + response.raise_for_status() + return response.json() + + def close_task(self, task_id: int, project_id: int) -> dict[str, Any]: + status_id = self.get_closed_status_id(project_id, for_task=True) + payload: dict[str, Any] = {"version": self._get_version("tasks", task_id)} + if status_id: + payload["status"] = status_id + else: + payload["is_closed"] = True + with self._client() as client: + response = client.patch( + f"/api/v1/tasks/{task_id}", + headers=self._headers(), + json=payload, + ) + response.raise_for_status() + return response.json() + + def get_by_ref( + self, project_id: int, ref: int, *, kind: str = "userstory" + ) -> dict[str, Any] | None: + endpoint = "/api/v1/userstories" if kind == "userstory" else "/api/v1/tasks" + with self._client() as client: + response = client.get( + endpoint, + params={"project": project_id, "ref": ref}, + headers=self._headers(), + ) + response.raise_for_status() + items = response.json() + return items[0] if items else None + + def _get_version(self, resource: str, item_id: int) -> int: + with self._client() as client: + response = client.get( + f"/api/v1/{resource}/{item_id}", + headers=self._headers(), + ) + response.raise_for_status() + return response.json().get("version", 1) + + def story_url(self, project_id: int, ref: int) -> str: + return f"{self.public_url}/project/0/{project_id}/us/{ref}" diff --git a/backend/app/projects/__init__.py b/backend/app/projects/__init__.py new file mode 100644 index 0000000..0294e43 --- /dev/null +++ b/backend/app/projects/__init__.py @@ -0,0 +1,3 @@ +from app.projects.service import ProjectService + +__all__ = ["ProjectService"] diff --git a/backend/app/projects/commit_parser.py b/backend/app/projects/commit_parser.py new file mode 100644 index 0000000..4488876 --- /dev/null +++ b/backend/app/projects/commit_parser.py @@ -0,0 +1,43 @@ +import re + +GITEA_PATTERNS = [ + re.compile(r"gitea\s*#(\d+)", re.IGNORECASE), + re.compile(r"fixes\s+#(\d+)", re.IGNORECASE), + re.compile(r"closes\s+gitea\s*#(\d+)", re.IGNORECASE), +] + +TAIGA_STORY_PATTERNS = [ + re.compile(r"taiga\s*#(\d+)", re.IGNORECASE), + re.compile(r"TG-(\d+)", re.IGNORECASE), + re.compile(r"closes\s+taiga\s*#(\d+)", re.IGNORECASE), +] + +TAIGA_TASK_PATTERNS = [ + re.compile(r"taiga\s+task\s*#(\d+)", re.IGNORECASE), +] + + +def parse_commit_message(message: str) -> dict[str, list[int]]: + gitea_refs: set[int] = set() + taiga_story_refs: set[int] = set() + taiga_task_refs: set[int] = set() + + for pattern in GITEA_PATTERNS: + for match in pattern.finditer(message): + gitea_refs.add(int(match.group(1))) + + for pattern in TAIGA_TASK_PATTERNS: + for match in pattern.finditer(message): + taiga_task_refs.add(int(match.group(1))) + + for pattern in TAIGA_STORY_PATTERNS: + for match in pattern.finditer(message): + ref = int(match.group(1)) + if ref not in taiga_task_refs: + taiga_story_refs.add(ref) + + return { + "gitea": sorted(gitea_refs), + "taiga_story": sorted(taiga_story_refs), + "taiga_task": sorted(taiga_task_refs), + } diff --git a/backend/app/projects/context.py b/backend/app/projects/context.py new file mode 100644 index 0000000..e08cb77 --- /dev/null +++ b/backend/app/projects/context.py @@ -0,0 +1,109 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.integrations.taiga import TaigaClient +from app.projects.service import ProjectService + +MAX_PROJECTS_IN_CONTEXT = 8 +MAX_OPEN_PER_PROJECT = 5 + + +def get_projects_snapshot(db: Session) -> dict[str, Any]: + settings = get_settings() + service = ProjectService(db) + + if not settings.taiga_configured: + return {"configured": False, "projects": [], "open_items": [], "taiga_open": []} + + projects = service.list_projects() + if not projects: + try: + projects = service.sync_taiga_projects() + except Exception: + projects = [] + + open_items = service.list_work_items(limit=15, status="open") + taiga_open: list[dict[str, Any]] = [] + + try: + client = TaigaClient() + for proj in projects[:MAX_PROJECTS_IN_CONTEXT]: + stories = client.list_open_userstories( + proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT + ) + if not stories: + continue + taiga_open.append( + { + "slug": proj["slug"], + "name": proj["name"], + "stories": [ + { + "ref": s.get("ref"), + "subject": s.get("subject", "")[:120], + } + for s in stories + ], + } + ) + except Exception: + pass + + return { + "configured": True, + "projects": projects, + "open_items": open_items, + "taiga_open": taiga_open, + } + + +def format_projects_context(snapshot: dict[str, Any]) -> str: + if not snapshot.get("configured"): + return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." + + lines = ["[Проекты и задачи — актуальный снимок для контекста]"] + + projects = snapshot.get("projects") or [] + if not projects: + lines.append("Проекты Taiga: кэш пуст. Попроси sync_taiga_projects или проверь подключение.") + else: + lines.append("Проекты Taiga:") + for p in projects[:MAX_PROJECTS_IN_CONTEXT]: + gitea = ( + f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" + if p.get("gitea_configured") + else "Gitea не привязан" + ) + lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") + + open_items = snapshot.get("open_items") or [] + if open_items: + lines.append("") + lines.append("Локальные work items (созданные ассистентом, открытые):") + for item in open_items[:10]: + gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" + lines.append( + f"- taiga #{item.get('taiga_ref')} {item.get('title')} " + f"({item.get('taiga_slug')}{gitea_part})" + ) + + taiga_open = snapshot.get("taiga_open") or [] + if taiga_open: + lines.append("") + lines.append("Открытые user stories в Taiga:") + for block in taiga_open: + lines.append(f" [{block.get('slug')}]") + for story in block.get("stories", []): + lines.append(f" - #{story.get('ref')} {story.get('subject')}") + elif projects: + lines.append("") + lines.append("Открытые user stories в Taiga: нет или не удалось загрузить.") + + lines.append("") + lines.append( + "Для создания фичи/бага из вольного текста используй create_work_item. " + "Не выдумывай номера задач — опирайся на список выше." + ) + return "\n".join(lines) diff --git a/backend/app/projects/service.py b/backend/app/projects/service.py new file mode 100644 index 0000000..9e37775 --- /dev/null +++ b/backend/app/projects/service.py @@ -0,0 +1,393 @@ +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db.models import ProjectBinding, TaigaProject, WorkItem +from app.integrations.gitea import GiteaClient +from app.integrations.taiga import TaigaClient +from app.projects.commit_parser import parse_commit_message +from app.projects.structuring import ( + format_gitea_body, + format_story_description, + slugify_branch, + structure_work_item, +) + + +class ProjectService: + def __init__(self, db: Session): + self.db = db + self.settings = get_settings() + + def sync_taiga_projects(self) -> list[dict[str, Any]]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена: задайте TAIGA_USERNAME и TAIGA_PASSWORD") + + client = TaigaClient() + remote = client.list_projects() + now = datetime.now(timezone.utc) + + for item in remote: + slug = item.get("slug") or "" + if not slug: + continue + existing = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == slug) + ) + if existing: + existing.name = item.get("name", slug) + existing.taiga_id = item["id"] + existing.synced_at = now + else: + self.db.add( + TaigaProject( + taiga_id=item["id"], + name=item.get("name", slug), + slug=slug, + synced_at=now, + ) + ) + self.db.commit() + return self.list_projects() + + def list_projects(self) -> list[dict[str, Any]]: + stmt = ( + select(TaigaProject, ProjectBinding) + .outerjoin(ProjectBinding, ProjectBinding.taiga_slug == TaigaProject.slug) + .order_by(TaigaProject.name) + ) + rows = self.db.execute(stmt).all() + result = [] + for taiga_proj, binding in rows: + result.append( + { + "taiga_id": taiga_proj.taiga_id, + "name": taiga_proj.name, + "slug": taiga_proj.slug, + "gitea_owner": binding.gitea_owner if binding else "", + "gitea_repo": binding.gitea_repo if binding else "", + "default_branch": binding.default_branch if binding else "main", + "gitea_configured": bool(binding and binding.gitea_owner and binding.gitea_repo), + } + ) + return result + + def bind_gitea( + self, + taiga_slug: str, + gitea_owner: str, + gitea_repo: str, + default_branch: str = "main", + ) -> dict[str, Any]: + if not self.db.scalar(select(TaigaProject).where(TaigaProject.slug == taiga_slug)): + raise ValueError(f"Проект Taiga '{taiga_slug}' не найден. Сначала sync-taiga.") + + binding = self.db.scalar( + select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_slug) + ) + if binding: + binding.gitea_owner = gitea_owner + binding.gitea_repo = gitea_repo + binding.default_branch = default_branch + else: + binding = ProjectBinding( + taiga_slug=taiga_slug, + gitea_owner=gitea_owner, + gitea_repo=gitea_repo, + default_branch=default_branch, + ) + self.db.add(binding) + self.db.commit() + + for proj in self.list_projects(): + if proj["slug"] == taiga_slug: + return proj + raise ValueError("Binding failed") + + def _resolve_project(self, slug: str | None) -> tuple[TaigaProject, ProjectBinding | None]: + projects = self.db.scalars(select(TaigaProject).order_by(TaigaProject.name)).all() + if not projects: + raise ValueError("Нет проектов Taiga. Вызовите sync_taiga_projects.") + + taiga_proj: TaigaProject | None = None + if slug: + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == slug) + ) + if not taiga_proj: + raise ValueError(f"Проект '{slug}' не найден") + else: + taiga_proj = projects[0] + + binding = self.db.scalar( + select(ProjectBinding).where(ProjectBinding.taiga_slug == taiga_proj.slug) + ) + return taiga_proj, binding + + async def create_work_item( + self, raw_text: str, project_slug: str | None = None + ) -> dict[str, Any]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена") + + project_list = self.list_projects() + if not project_list: + self.sync_taiga_projects() + project_list = self.list_projects() + + structured = await structure_work_item(raw_text, project_list) + slug = project_slug or structured.get("project_slug") + taiga_proj, binding = self._resolve_project(slug) + + if binding and not (binding.gitea_owner and binding.gitea_repo): + binding = None + + taiga = TaigaClient() + title = (structured.get("title") or raw_text).strip()[:500] + description = format_story_description(structured, raw_text) + tags = structured.get("tags") or [] + issue_type = structured.get("issue_type", "feature") + if issue_type == "bug" and "bug" not in [t.lower() for t in tags]: + tags.append("bug") + + story = taiga.create_userstory( + taiga_proj.taiga_id, + title, + description, + tags=tags, + ) + + subtasks = [] + for child in structured.get("children") or []: + if isinstance(child, dict): + subtasks.append( + taiga.create_task( + taiga_proj.taiga_id, + story["id"], + child.get("title", "Подзадача"), + child.get("description", ""), + ) + ) + + branch = f"feature/{story['ref']}-{slugify_branch(title)}" + gitea_issue_number = None + gitea_url = "" + labels = [issue_type] if issue_type else [] + + if binding and self.settings.gitea_configured: + gitea = GiteaClient() + gitea_body = format_gitea_body( + structured, + raw_text, + story["ref"], + taiga.story_url(taiga_proj.taiga_id, story["ref"]), + branch, + ) + issue = gitea.create_issue( + binding.gitea_owner, + binding.gitea_repo, + title, + gitea_body, + labels=labels, + ) + gitea_issue_number = issue["number"] + gitea_url = gitea.issue_url( + binding.gitea_owner, binding.gitea_repo, gitea_issue_number + ) + + work_item = WorkItem( + taiga_slug=taiga_proj.slug, + taiga_project_id=taiga_proj.taiga_id, + taiga_story_id=story["id"], + taiga_story_ref=story["ref"], + gitea_owner=binding.gitea_owner if binding else "", + gitea_repo=binding.gitea_repo if binding else "", + gitea_issue_number=gitea_issue_number, + suggested_branch=branch, + raw_text=raw_text, + title=title, + status="open", + ) + self.db.add(work_item) + self.db.commit() + self.db.refresh(work_item) + + return { + "ok": True, + "work_item_id": work_item.id, + "taiga": { + "ref": story["ref"], + "id": story["id"], + "subject": story["subject"], + "url": taiga.story_url(taiga_proj.taiga_id, story["ref"]), + }, + "gitea": { + "number": gitea_issue_number, + "url": gitea_url, + }, + "branch": branch, + "labels": labels, + "subtasks": [{"ref": t.get("ref"), "subject": t.get("subject")} for t in subtasks], + "questions": structured.get("questions") or [], + "project_slug": taiga_proj.slug, + } + + def process_push( + self, owner: str, repo: str, commits: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + if not self.settings.taiga_configured: + return [] + + taiga = TaigaClient() + gitea = GiteaClient() if self.settings.gitea_configured else None + results: list[dict[str, Any]] = [] + + for commit in commits: + message = commit.get("message", "") + parsed = parse_commit_message(message) + sha = commit.get("id", "")[:8] + + gitea_refs = set(parsed["gitea"]) + taiga_story_refs = set(parsed["taiga_story"]) + taiga_task_refs = set(parsed["taiga_task"]) + + linked_items = self.db.scalars( + select(WorkItem).where( + WorkItem.gitea_owner == owner, + WorkItem.gitea_repo == repo, + WorkItem.status == "open", + ) + ).all() + + for item in linked_items: + if item.gitea_issue_number and item.gitea_issue_number in gitea_refs: + taiga_story_refs.add(item.taiga_story_ref) + if item.taiga_story_ref in taiga_story_refs and item.gitea_issue_number: + gitea_refs.add(item.gitea_issue_number) + + for gitea_num in gitea_refs: + if gitea: + try: + gitea.close_issue(owner, repo, gitea_num) + except Exception as exc: + results.append({"error": f"gitea #{gitea_num}: {exc}"}) + continue + + for item in linked_items: + if item.gitea_issue_number == gitea_num: + self._close_work_item(item, taiga) + results.append( + { + "commit": sha, + "closed": f"gitea #{gitea_num}, taiga #{item.taiga_story_ref}", + } + ) + + for ref in taiga_story_refs: + project_id = self._project_id_for_ref(owner, repo, ref, linked_items) + if not project_id: + continue + story = taiga.get_by_ref(project_id, ref, kind="userstory") + if story: + taiga.close_userstory(story["id"], project_id) + for item in linked_items: + if item.taiga_story_ref == ref: + self._close_work_item(item, taiga, close_gitea=bool(gitea)) + results.append({"commit": sha, "closed": f"taiga #{ref}"}) + + for ref in taiga_task_refs: + binding = self.db.scalar( + select(ProjectBinding).where( + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo, + ) + ) + if not binding: + continue + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) + ) + if not taiga_proj: + continue + task = taiga.get_by_ref(taiga_proj.taiga_id, ref, kind="task") + if task: + taiga.close_task(task["id"], taiga_proj.taiga_id) + results.append({"commit": sha, "closed": f"taiga task #{ref}"}) + + self.db.commit() + return results + + def _project_id_for_ref( + self, + owner: str, + repo: str, + ref: int, + items: list[WorkItem], + ) -> int | None: + for item in items: + if item.taiga_story_ref == ref: + return item.taiga_project_id + binding = self.db.scalar( + select(ProjectBinding).where( + ProjectBinding.gitea_owner == owner, + ProjectBinding.gitea_repo == repo, + ) + ) + if binding: + taiga_proj = self.db.scalar( + select(TaigaProject).where(TaigaProject.slug == binding.taiga_slug) + ) + return taiga_proj.taiga_id if taiga_proj else None + return None + + def _close_work_item( + self, + item: WorkItem, + taiga: TaigaClient, + *, + close_gitea: bool = True, + ) -> None: + if item.status == "closed": + return + story = taiga.get_by_ref(item.taiga_project_id, item.taiga_story_ref, kind="userstory") + if story: + taiga.close_userstory(story["id"], item.taiga_project_id) + if ( + close_gitea + and item.gitea_issue_number + and self.settings.gitea_configured + ): + GiteaClient().close_issue( + item.gitea_owner, item.gitea_repo, item.gitea_issue_number + ) + item.status = "closed" + item.closed_at = datetime.now(timezone.utc) + + def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: + stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit) + if status: + stmt = stmt.where(WorkItem.status == status) + items = self.db.scalars(stmt).all() + settings = get_settings() + return [ + { + "id": i.id, + "title": i.title, + "status": i.status, + "taiga_slug": i.taiga_slug, + "taiga_ref": i.taiga_story_ref, + "gitea_issue": i.gitea_issue_number, + "branch": i.suggested_branch, + "taiga_url": f"{settings.taiga_public_url}/project/0/{i.taiga_project_id}/us/{i.taiga_story_ref}", + "gitea_url": ( + f"{settings.gitea_public_url}/{i.gitea_owner}/{i.gitea_repo}/issues/{i.gitea_issue_number}" + if i.gitea_issue_number + else "" + ), + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in items + ] diff --git a/backend/app/projects/structuring.py b/backend/app/projects/structuring.py new file mode 100644 index 0000000..44ab935 --- /dev/null +++ b/backend/app/projects/structuring.py @@ -0,0 +1,104 @@ +import json +import re +from typing import Any + +from app.llm.client import LLMClient + + +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 slugify_branch(title: str, max_len: int = 40) -> str: + text = title.lower() + text = re.sub(r"[^a-z0-9а-яё]+", "-", text, flags=re.IGNORECASE) + text = re.sub(r"-+", "-", text).strip("-") + return text[:max_len] or "task" + + +async def structure_work_item( + raw_text: str, + projects: list[dict[str, Any]], +) -> dict[str, Any]: + project_lines = "\n".join( + f"- {p['slug']}: {p['name']} (id={p['taiga_id']})" for p in projects + ) + system_prompt = f""" +Ты технический ассистент. Преобразуй сырое описание фичи или бага в строгий JSON. +Отвечай только JSON, без markdown. + +Доступные проекты Taiga: +{project_lines} + +Схема: +{{ + "project_slug": "slug проекта из списка", + "title": "короткое название", + "description": "понятное описание", + "issue_type": "feature|bug", + "priority": "low|normal|high", + "tags": ["tag1"], + "acceptance_criteria": ["критерий 1"], + "children": [ + {{"title": "подзадача", "description": "описание", "type": "Task"}} + ], + "questions": ["уточняющий вопрос если данных мало"] +}} + +Правила: +- Пиши на русском. +- project_slug выбери из списка; если неясно — первый подходящий или спроси в questions. +- acceptance_criteria проверяемые. +- children — технические подзадачи. +""".strip() + + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": raw_text}, + ] + ) + content = strip_markdown_json(result.get("content") or "") + return json.loads(content) + + +def format_story_description(task: dict[str, Any], raw_text: str) -> str: + lines = [task.get("description") or raw_text] + + acceptance = task.get("acceptance_criteria") or [] + if acceptance: + lines.append("") + lines.append("## Acceptance criteria") + for item in acceptance: + lines.append(f"- {item}") + + questions = task.get("questions") or [] + if questions: + lines.append("") + lines.append("## Вопросы") + for item in questions: + lines.append(f"- {item}") + + lines.append("") + lines.append("## Исходное описание") + lines.append(raw_text) + return "\n".join(lines).strip() + + +def format_gitea_body( + task: dict[str, Any], + raw_text: str, + taiga_ref: int, + taiga_url: str, + branch: str, +) -> str: + body = format_story_description(task, raw_text) + body += f"\n\n---\n**Taiga:** #{taiga_ref} — {taiga_url}\n" + body += f"**Ветка:** `{branch}`\n" + body += "\nЗакрытие: `Closes gitea #N, taiga #REF` в коммите" + return body diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 608dc2f..71ad201 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -4,6 +4,7 @@ from typing import Any from sqlalchemy.orm import Session from app.pomodoro.service import PomodoroService +from app.projects.service import ProjectService TOOL_DEFINITIONS: list[dict[str, Any]] = [ { @@ -104,7 +105,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_pomodoro_history", - "description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.", + "description": "История помидоро-сессий (таймер), не Taiga-задачи.", "parameters": { "type": "object", "properties": { @@ -114,44 +115,107 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "sync_taiga_projects", + "description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "list_taiga_projects", + "description": "Список проектов Taiga с привязкой Gitea.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "create_work_item", + "description": ( + "Создать фичу/баг из вольного текста: структурировать через LLM, " + "создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»." + ), + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Полное описание от пользователя"}, + "project_slug": { + "type": "string", + "description": "Slug проекта Taiga, если известен", + }, + }, + "required": ["text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_work_items", + "description": "Список созданных work items (Taiga + Gitea связки).", + "parameters": { + "type": "object", + "properties": { + "status": {"type": "string", "description": "open или closed"}, + "limit": {"type": "integer"}, + }, + "required": [], + }, + }, + }, ] -def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str: - service = PomodoroService(db) +async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str: + pomodoro = PomodoroService(db) + projects = ProjectService(db) try: if name == "get_pomodoro_status": - result = service.get_status() + result = pomodoro.get_status() elif name == "start_pomodoro": - result = service.start_work( + result = pomodoro.start_work( duration_min=arguments.get("duration_min"), task_note=arguments.get("task_note", ""), ) elif name == "start_short_break": - result = service.start_short_break( - duration_min=arguments.get("duration_min"), - ) + result = pomodoro.start_short_break(duration_min=arguments.get("duration_min")) elif name == "start_long_break": - result = service.start_long_break( - duration_min=arguments.get("duration_min"), - ) + result = pomodoro.start_long_break(duration_min=arguments.get("duration_min")) elif name == "stop_pomodoro": - result = service.stop( + result = pomodoro.stop( result=arguments.get("result", ""), completed=arguments.get("completed", False), ) elif name == "skip_pomodoro_phase": - result = service.skip_phase() + result = pomodoro.skip_phase() elif name == "reset_pomodoro_cycle": - result = service.reset_cycle( - clear_task=arguments.get("clear_task", False), - ) + result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False)) elif name == "get_pomodoro_history": - result = service.history(limit=arguments.get("limit", 10)) + result = pomodoro.history(limit=arguments.get("limit", 10)) + elif name == "sync_taiga_projects": + result = projects.sync_taiga_projects() + elif name == "list_taiga_projects": + result = projects.list_projects() + elif name == "create_work_item": + result = await projects.create_work_item( + arguments.get("text", ""), + project_slug=arguments.get("project_slug"), + ) + elif name == "list_work_items": + result = projects.list_work_items( + limit=arguments.get("limit", 20), + status=arguments.get("status"), + ) else: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps(result, ensure_ascii=False) except ValueError as exc: return json.dumps({"error": str(exc)}, ensure_ascii=False) + except Exception as exc: + return json.dumps({"error": str(exc)}, ensure_ascii=False) diff --git a/backend/requirements.txt b/backend/requirements.txt index 20213c3..d73d6fc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ pydantic-settings>=2.6.0 openai>=1.55.0 python-dotenv>=1.0.1 aiosqlite>=0.20.0 +httpx>=0.28.0 diff --git a/docker-compose.yml b/docker-compose.yml index 5b1e4be..db9e27d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: env_file: .env volumes: - ./data:/app/data + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped frontend: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d42d957..67652ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { NavLink, Route, Routes } from "react-router-dom"; import PomodoroWidget from "./components/PomodoroWidget"; +import { PomodoroProvider } from "./context/PomodoroContext"; import Character from "./pages/Character"; import Chat from "./pages/Chat"; import Pomodoro from "./pages/Pomodoro"; @@ -7,6 +8,7 @@ import "./App.css"; export default function App() { return ( +

Home AI Assistant

@@ -27,5 +29,6 @@ export default function App() {
+
); } diff --git a/frontend/src/components/PomodoroWidget.css b/frontend/src/components/PomodoroWidget.css index f2b27d6..24a0832 100644 --- a/frontend/src/components/PomodoroWidget.css +++ b/frontend/src/components/PomodoroWidget.css @@ -9,22 +9,43 @@ transition: border-color 0.15s; } +.pomodoro-widget.compact { + padding: 0.35rem 0.5rem; + background: transparent; + border: none; +} + .pomodoro-widget:hover { border-color: #4f7cff; } +.pomodoro-widget.compact:hover { + border-color: transparent; + opacity: 0.9; +} + +.pomodoro-widget-body { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pomodoro-widget.compact .pomodoro-widget-body { + gap: 0.35rem; +} + .pomodoro-widget-ring { width: 88px; height: 88px; border-radius: 50%; - margin: 0 auto; + flex-shrink: 0; display: grid; place-items: center; } .pomodoro-widget.compact .pomodoro-widget-ring { - width: 44px; - height: 44px; + width: 40px; + height: 40px; } .pomodoro-widget-inner { @@ -39,8 +60,8 @@ } .pomodoro-widget.compact .pomodoro-widget-inner { - width: 36px; - height: 36px; + width: 32px; + height: 32px; } .pomodoro-widget-time { @@ -50,7 +71,7 @@ } .pomodoro-widget.compact .pomodoro-widget-time { - font-size: 0.55rem; + font-size: 0.5rem; } .pomodoro-widget-label { @@ -64,14 +85,20 @@ } .pomodoro-widget-cycle { - margin: 0.45rem 0 0; + font-size: 0.85rem; + font-weight: 600; + color: #c5ccd6; + white-space: nowrap; + min-width: 2.5rem; +} + +.pomodoro-widget.compact .pomodoro-widget-cycle { font-size: 0.75rem; color: #8b95a5; - text-align: center; } .pomodoro-widget-task { - margin: 0.25rem 0 0; + margin: 0.5rem 0 0; font-size: 0.8rem; color: #a8b0bd; text-align: center; diff --git a/frontend/src/components/PomodoroWidget.tsx b/frontend/src/components/PomodoroWidget.tsx index 697c1a2..08fc456 100644 --- a/frontend/src/components/PomodoroWidget.tsx +++ b/frontend/src/components/PomodoroWidget.tsx @@ -1,7 +1,7 @@ import { Link } from "react-router-dom"; -import { usePomodoro } from "../hooks/usePomodoro"; +import { usePomodoro } from "../context/PomodoroContext"; import { formatTime } from "../utils/time"; -import { phaseLabel } from "../utils/pomodoro"; +import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; import "./PomodoroWidget.css"; interface PomodoroWidgetProps { @@ -19,30 +19,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 : 0; const cycle = status.cycle; + const cycleLabel = formatCycleLabel(cycle, status.phase, isActive); const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f"; return ( -
-
- {formatTime(displaySeconds)} - - {isActive ? phaseLabel(status.phase) : "помидоро"} - +
+
+
+ {formatTime(displaySeconds)} + + {isActive ? phaseLabel(status.phase) : "помидоро"} + +
+ + + {cycleLabel} +
- {!compact && ( - <> - {cycle && ( -

- Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} -

- )} - {status.task_note &&

{status.task_note}

} - + + {!compact && status.task_note && ( +

{status.task_note}

)} ); diff --git a/frontend/src/context/PomodoroContext.tsx b/frontend/src/context/PomodoroContext.tsx new file mode 100644 index 0000000..ff4ef8f --- /dev/null +++ b/frontend/src/context/PomodoroContext.tsx @@ -0,0 +1,54 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { api, PomodoroStatus } from "../api/client"; + +interface PomodoroContextValue { + status: PomodoroStatus | null; + error: string | null; + refresh: () => Promise; +} + +const PomodoroContext = createContext(null); + +export function PomodoroProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const data = await api.pomodoroStatus(); + setStatus(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); + } + }, []); + + useEffect(() => { + refresh().catch(console.error); + const timer = setInterval(() => { + refresh().catch(console.error); + }, 1000); + return () => clearInterval(timer); + }, [refresh]); + + return ( + + {children} + + ); +} + +export function usePomodoro() { + const ctx = useContext(PomodoroContext); + if (!ctx) { + throw new Error("usePomodoro must be used within PomodoroProvider"); + } + return ctx; +} diff --git a/frontend/src/hooks/usePomodoro.ts b/frontend/src/hooks/usePomodoro.ts deleted file mode 100644 index 4902e7f..0000000 --- a/frontend/src/hooks/usePomodoro.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { api, PomodoroStatus } from "../api/client"; - -export function usePomodoro(pollMs = 1000) { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - try { - const data = await api.pomodoroStatus(); - setStatus(data); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); - } - }, []); - - useEffect(() => { - refresh().catch(console.error); - const timer = setInterval(() => { - refresh().catch(console.error); - }, pollMs); - return () => clearInterval(timer); - }, [refresh, pollMs]); - - return { status, error, refresh }; -} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 44e76c6..f707799 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import { api, ChatMessage, ChatSession } from "../api/client"; import PomodoroWidget from "../components/PomodoroWidget"; -import { usePomodoro } from "../hooks/usePomodoro"; +import { usePomodoro } from "../context/PomodoroContext"; import "./Chat.css"; function shouldShowMessage(msg: ChatMessage): boolean { @@ -58,11 +58,14 @@ export default function Chat() { useEffect(() => { const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0; - if (seq > lastNotifySeq && activeId) { + if (seq > lastNotifySeq) { setLastNotifySeq(seq); - loadMessages(activeId).catch(console.error); + refreshPomodoro().catch(console.error); + if (activeId) { + loadMessages(activeId).catch(console.error); + } } - }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]); + }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]); const handleNewChat = async () => { const session = await api.createSession(); diff --git a/frontend/src/pages/Pomodoro.tsx b/frontend/src/pages/Pomodoro.tsx index bb5869f..f2985c2 100644 --- a/frontend/src/pages/Pomodoro.tsx +++ b/frontend/src/pages/Pomodoro.tsx @@ -1,11 +1,12 @@ import { FormEvent, useEffect, useState } from "react"; -import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client"; -import { phaseLabel } from "../utils/pomodoro"; +import { api, PomodoroHistoryItem } from "../api/client"; +import { usePomodoro } from "../context/PomodoroContext"; +import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; import { formatTime } from "../utils/time"; import "./Pomodoro.css"; export default function Pomodoro() { - const [status, setStatus] = useState(null); + const { status, refresh } = usePomodoro(); const [history, setHistory] = useState([]); const [duration, setDuration] = useState(25); const [taskNote, setTaskNote] = useState(""); @@ -13,32 +14,31 @@ export default function Pomodoro() { const [completed, setCompleted] = useState(false); const [error, setError] = useState(""); - const refresh = async () => { - const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]); - setStatus(current); + const loadHistory = async () => { + const past = await api.pomodoroHistory(); setHistory(past); - if (current.cycle?.work_duration_min) { - setDuration(current.cycle.work_duration_min); - } - if (current.cycle?.task_note) { - setTaskNote(current.cycle.task_note); - } }; useEffect(() => { - refresh().catch(console.error); - const timer = setInterval(() => { - api.pomodoroStatus().then(setStatus).catch(console.error); - }, 1000); - return () => clearInterval(timer); + loadHistory().catch(console.error); }, []); + useEffect(() => { + if (status?.cycle?.work_duration_min) { + setDuration(status.cycle.work_duration_min); + } + if (status?.cycle?.task_note) { + setTaskNote(status.cycle.task_note); + } + }, [status?.cycle?.work_duration_min, status?.cycle?.task_note]); + const handleStartWork = async (e: FormEvent) => { e.preventDefault(); setError(""); try { - const data = await api.pomodoroStart(duration, taskNote); - setStatus(data); + await api.pomodoroStart(duration, taskNote); + await refresh(); + await loadHistory(); setResult(""); setCompleted(false); } catch (err) { @@ -49,7 +49,8 @@ export default function Pomodoro() { const handlePause = async () => { setError(""); try { - setStatus(await api.pomodoroPause()); + await api.pomodoroPause(); + await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка"); } @@ -58,7 +59,8 @@ export default function Pomodoro() { const handleResume = async () => { setError(""); try { - setStatus(await api.pomodoroResume()); + await api.pomodoroResume(); + await refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка"); } @@ -69,6 +71,7 @@ export default function Pomodoro() { try { await api.pomodoroStop(result, completed); await refresh(); + await loadHistory(); setResult(""); setCompleted(false); } catch (err) { @@ -81,6 +84,7 @@ export default function Pomodoro() { try { await api.pomodoroSkip(); await refresh(); + await loadHistory(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка"); } @@ -91,6 +95,7 @@ export default function Pomodoro() { try { await api.pomodoroResetCycle(false); await refresh(); + await loadHistory(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка"); } @@ -102,6 +107,7 @@ export default function Pomodoro() { ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 : 0; const cycle = status?.cycle; + const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive); const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f"; return ( @@ -109,7 +115,7 @@ export default function Pomodoro() {
{cycle && (
- Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} + Цикл {cycleLabel} {cycle.auto_advance && " · авто"}
)} @@ -152,10 +158,16 @@ export default function Pomodoro() { - -
diff --git a/frontend/src/utils/pomodoro.ts b/frontend/src/utils/pomodoro.ts index b90b99f..9beb3ed 100644 --- a/frontend/src/utils/pomodoro.ts +++ b/frontend/src/utils/pomodoro.ts @@ -1,3 +1,5 @@ +import { PomodoroCycle } from "../api/client"; + export const PHASE_LABELS: Record = { work: "Работа", short_break: "Перерыв", @@ -7,3 +9,28 @@ export const PHASE_LABELS: Record = { export function phaseLabel(phase: string): string { return PHASE_LABELS[phase] ?? phase; } + +/** Текущий номер помидоро в цикле (1..N), а не только завершённые. */ +export function cycleProgress( + cycle: PomodoroCycle | undefined, + phase: string, + isActive: boolean +): { current: number; total: number } { + const total = cycle?.sessions_until_long_break ?? 4; + let current = cycle?.completed_work_sessions ?? 0; + + if (isActive && phase === "work") { + current += 1; + } + + return { current, total }; +} + +export function formatCycleLabel( + cycle: PomodoroCycle | undefined, + phase: string, + isActive: boolean +): string { + const { current, total } = cycleProgress(cycle, phase, isActive); + return `${current}/${total}`; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 91a488e..9760e64 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/time.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/context/pomodorocontext.tsx","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/time.ts"],"version":"5.9.3"} \ No newline at end of file