Taiga integration

This commit is contained in:
2026-06-09 12:47:13 +03:00
parent c8599b3d13
commit 1f83dcb574
30 changed files with 1543 additions and 115 deletions
+15 -7
View File
@@ -15,16 +15,24 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# App # App
DATABASE_URL=sqlite:///./data/assistant.db 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 CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
SYSTEM_PROMPT_PATH=./prompts/assistant.md SYSTEM_PROMPT_PATH=./prompts/assistant.md
# External services (phase 2 — homelab integrations) # Taiga (on host :9000, nginx → taiga.grigowashere.ru)
TAIGA_BASE_URL=http://taiga:9000 TAIGA_BASE_URL=http://host.docker.internal:9000
TAIGA_PORT=9000 TAIGA_USERNAME=your_taiga_user
GITEA_BASE_URL=http://gitea:3000 TAIGA_PASSWORD=your_taiga_password
GITEA_PORT=3000 TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
GITEA_SSH_PORT=222
# 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 REPOS_DIR=/data/repos
# Vector DB (phase 3) # Vector DB (phase 3)
+64 -1
View File
@@ -87,6 +87,70 @@ Vite dev-server: http://localhost:5173 (проксирует `/api` на backend
| POST | `/api/v1/pomodoro/resume` | Продолжить | | POST | `/api/v1/pomodoro/resume` | Продолжить |
| POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` | | POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` |
| GET | `/api/v1/pomodoro/history` | История сессий | | 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 для документов - RAG с Qdrant для документов
- Проактивные чаты по расписанию - Проактивные чаты по расписанию
- Фитнес-трекер - Фитнес-трекер
+3 -1
View File
@@ -1,9 +1,11 @@
from fastapi import APIRouter 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 = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"]) api_router.include_router(health.router, tags=["health"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"]) api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(webhooks.router, tags=["webhooks"])
+76
View File
@@ -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)
+94
View File
@@ -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}
+4 -2
View File
@@ -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. 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() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
+54
View File
@@ -67,9 +67,63 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
if tool_name == "get_pomodoro_history": if tool_name == "get_pomodoro_history":
return _format_history_notice(data) 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 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: def _format_status_notice(data: dict[str, Any]) -> str:
status = data.get("status", "idle") status = data.get("status", "idle")
phase = data.get("phase", PHASE_WORK) phase = data.get("phase", PHASE_WORK)
+5 -2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app.character.service import CharacterService from app.character.service import CharacterService
from app.chat.notices import format_pomodoro_context, format_pomodoro_notice 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.db.models import ChatSession, Message
from app.llm.client import LLMClient from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
@@ -45,9 +46,11 @@ class ChatService:
def _build_system_prompt(self) -> str: def _build_system_prompt(self) -> str:
status = PomodoroService(self.db).get_status() status = PomodoroService(self.db).get_status()
projects_snapshot = get_projects_snapshot(self.db)
return ( return (
f"{self.character.get_system_prompt()}\n\n" 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]]: def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
@@ -129,7 +132,7 @@ class ChatService:
for tool_call in tool_calls: for tool_call in tool_calls:
fn = tool_call["function"] fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) 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 = { tool_message = {
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],
+21
View File
@@ -22,10 +22,31 @@ class Settings(BaseSettings):
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
system_prompt_path: str = "./prompts/assistant.md" 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 @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] 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: def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path) path = Path(self.system_prompt_path)
if path.is_file(): if path.is_file():
+42
View File
@@ -68,3 +68,45 @@ class PomodoroSession(Base):
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0) elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 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()) 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)
+54
View File
@@ -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}"
+190
View File
@@ -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}"
+3
View File
@@ -0,0 +1,3 @@
from app.projects.service import ProjectService
__all__ = ["ProjectService"]
+43
View File
@@ -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),
}
+109
View File
@@ -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)
+393
View File
@@ -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
]
+104
View File
@@ -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
+81 -17
View File
@@ -4,6 +4,7 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService
TOOL_DEFINITIONS: list[dict[str, Any]] = [ TOOL_DEFINITIONS: list[dict[str, Any]] = [
{ {
@@ -104,7 +105,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_pomodoro_history", "name": "get_pomodoro_history",
"description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.", "description": "История помидоро-сессий (таймер), не Taiga-задачи.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "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: async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
service = PomodoroService(db) pomodoro = PomodoroService(db)
projects = ProjectService(db)
try: try:
if name == "get_pomodoro_status": if name == "get_pomodoro_status":
result = service.get_status() result = pomodoro.get_status()
elif name == "start_pomodoro": elif name == "start_pomodoro":
result = service.start_work( result = pomodoro.start_work(
duration_min=arguments.get("duration_min"), duration_min=arguments.get("duration_min"),
task_note=arguments.get("task_note", ""), task_note=arguments.get("task_note", ""),
) )
elif name == "start_short_break": elif name == "start_short_break":
result = service.start_short_break( result = pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
duration_min=arguments.get("duration_min"),
)
elif name == "start_long_break": elif name == "start_long_break":
result = service.start_long_break( result = pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
duration_min=arguments.get("duration_min"),
)
elif name == "stop_pomodoro": elif name == "stop_pomodoro":
result = service.stop( result = pomodoro.stop(
result=arguments.get("result", ""), result=arguments.get("result", ""),
completed=arguments.get("completed", False), completed=arguments.get("completed", False),
) )
elif name == "skip_pomodoro_phase": elif name == "skip_pomodoro_phase":
result = service.skip_phase() result = pomodoro.skip_phase()
elif name == "reset_pomodoro_cycle": elif name == "reset_pomodoro_cycle":
result = service.reset_cycle( result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
clear_task=arguments.get("clear_task", False),
)
elif name == "get_pomodoro_history": 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: else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False) return json.dumps({"error": str(exc)}, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)}, ensure_ascii=False)
+1
View File
@@ -5,3 +5,4 @@ pydantic-settings>=2.6.0
openai>=1.55.0 openai>=1.55.0
python-dotenv>=1.0.1 python-dotenv>=1.0.1
aiosqlite>=0.20.0 aiosqlite>=0.20.0
httpx>=0.28.0
+2
View File
@@ -6,6 +6,8 @@ services:
env_file: .env env_file: .env
volumes: volumes:
- ./data:/app/data - ./data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
+3
View File
@@ -1,5 +1,6 @@
import { NavLink, Route, Routes } from "react-router-dom"; import { NavLink, Route, Routes } from "react-router-dom";
import PomodoroWidget from "./components/PomodoroWidget"; import PomodoroWidget from "./components/PomodoroWidget";
import { PomodoroProvider } from "./context/PomodoroContext";
import Character from "./pages/Character"; import Character from "./pages/Character";
import Chat from "./pages/Chat"; import Chat from "./pages/Chat";
import Pomodoro from "./pages/Pomodoro"; import Pomodoro from "./pages/Pomodoro";
@@ -7,6 +8,7 @@ import "./App.css";
export default function App() { export default function App() {
return ( return (
<PomodoroProvider>
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header">
<h1>Home AI Assistant</h1> <h1>Home AI Assistant</h1>
@@ -27,5 +29,6 @@ export default function App() {
</Routes> </Routes>
</main> </main>
</div> </div>
</PomodoroProvider>
); );
} }
+36 -9
View File
@@ -9,22 +9,43 @@
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.pomodoro-widget.compact {
padding: 0.35rem 0.5rem;
background: transparent;
border: none;
}
.pomodoro-widget:hover { .pomodoro-widget:hover {
border-color: #4f7cff; 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 { .pomodoro-widget-ring {
width: 88px; width: 88px;
height: 88px; height: 88px;
border-radius: 50%; border-radius: 50%;
margin: 0 auto; flex-shrink: 0;
display: grid; display: grid;
place-items: center; place-items: center;
} }
.pomodoro-widget.compact .pomodoro-widget-ring { .pomodoro-widget.compact .pomodoro-widget-ring {
width: 44px; width: 40px;
height: 44px; height: 40px;
} }
.pomodoro-widget-inner { .pomodoro-widget-inner {
@@ -39,8 +60,8 @@
} }
.pomodoro-widget.compact .pomodoro-widget-inner { .pomodoro-widget.compact .pomodoro-widget-inner {
width: 36px; width: 32px;
height: 36px; height: 32px;
} }
.pomodoro-widget-time { .pomodoro-widget-time {
@@ -50,7 +71,7 @@
} }
.pomodoro-widget.compact .pomodoro-widget-time { .pomodoro-widget.compact .pomodoro-widget-time {
font-size: 0.55rem; font-size: 0.5rem;
} }
.pomodoro-widget-label { .pomodoro-widget-label {
@@ -64,14 +85,20 @@
} }
.pomodoro-widget-cycle { .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; font-size: 0.75rem;
color: #8b95a5; color: #8b95a5;
text-align: center;
} }
.pomodoro-widget-task { .pomodoro-widget-task {
margin: 0.25rem 0 0; margin: 0.5rem 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: #a8b0bd; color: #a8b0bd;
text-align: center; text-align: center;
+12 -11
View File
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { usePomodoro } from "../hooks/usePomodoro"; import { usePomodoro } from "../context/PomodoroContext";
import { formatTime } from "../utils/time"; import { formatTime } from "../utils/time";
import { phaseLabel } from "../utils/pomodoro"; import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
import "./PomodoroWidget.css"; import "./PomodoroWidget.css";
interface PomodoroWidgetProps { interface PomodoroWidgetProps {
@@ -19,10 +19,12 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0; : 0;
const cycle = status.cycle; const cycle = status.cycle;
const cycleLabel = formatCycleLabel(cycle, status.phase, isActive);
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f"; const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
return ( return (
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}> <Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
<div className="pomodoro-widget-body">
<div <div
className="pomodoro-widget-ring" className="pomodoro-widget-ring"
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }} style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
@@ -34,15 +36,14 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
</span> </span>
</div> </div>
</div> </div>
{!compact && (
<> <span className="pomodoro-widget-cycle" title="Прогресс цикла">
{cycle && ( {cycleLabel}
<p className="pomodoro-widget-cycle"> </span>
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} </div>
</p>
)} {!compact && status.task_note && (
{status.task_note && <p className="pomodoro-widget-task">{status.task_note}</p>} <p className="pomodoro-widget-task">{status.task_note}</p>
</>
)} )}
</Link> </Link>
); );
+54
View File
@@ -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<void>;
}
const PomodoroContext = createContext<PomodoroContextValue | null>(null);
export function PomodoroProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<PomodoroStatus | null>(null);
const [error, setError] = useState<string | null>(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 (
<PomodoroContext.Provider value={{ status, error, refresh }}>
{children}
</PomodoroContext.Provider>
);
}
export function usePomodoro() {
const ctx = useContext(PomodoroContext);
if (!ctx) {
throw new Error("usePomodoro must be used within PomodoroProvider");
}
return ctx;
}
-27
View File
@@ -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<PomodoroStatus | null>(null);
const [error, setError] = useState<string | null>(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 };
}
+6 -3
View File
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client"; import { api, ChatMessage, ChatSession } from "../api/client";
import PomodoroWidget from "../components/PomodoroWidget"; import PomodoroWidget from "../components/PomodoroWidget";
import { usePomodoro } from "../hooks/usePomodoro"; import { usePomodoro } from "../context/PomodoroContext";
import "./Chat.css"; import "./Chat.css";
function shouldShowMessage(msg: ChatMessage): boolean { function shouldShowMessage(msg: ChatMessage): boolean {
@@ -58,11 +58,14 @@ export default function Chat() {
useEffect(() => { useEffect(() => {
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0; const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
if (seq > lastNotifySeq && activeId) { if (seq > lastNotifySeq) {
setLastNotifySeq(seq); setLastNotifySeq(seq);
refreshPomodoro().catch(console.error);
if (activeId) {
loadMessages(activeId).catch(console.error); loadMessages(activeId).catch(console.error);
} }
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]); }
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]);
const handleNewChat = async () => { const handleNewChat = async () => {
const session = await api.createSession(); const session = await api.createSession();
+36 -24
View File
@@ -1,11 +1,12 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client"; import { api, PomodoroHistoryItem } from "../api/client";
import { phaseLabel } from "../utils/pomodoro"; import { usePomodoro } from "../context/PomodoroContext";
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
import { formatTime } from "../utils/time"; import { formatTime } from "../utils/time";
import "./Pomodoro.css"; import "./Pomodoro.css";
export default function Pomodoro() { export default function Pomodoro() {
const [status, setStatus] = useState<PomodoroStatus | null>(null); const { status, refresh } = usePomodoro();
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]); const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
const [duration, setDuration] = useState(25); const [duration, setDuration] = useState(25);
const [taskNote, setTaskNote] = useState(""); const [taskNote, setTaskNote] = useState("");
@@ -13,32 +14,31 @@ export default function Pomodoro() {
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const refresh = async () => { const loadHistory = async () => {
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]); const past = await api.pomodoroHistory();
setStatus(current);
setHistory(past); 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(() => { useEffect(() => {
refresh().catch(console.error); loadHistory().catch(console.error);
const timer = setInterval(() => {
api.pomodoroStatus().then(setStatus).catch(console.error);
}, 1000);
return () => clearInterval(timer);
}, []); }, []);
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) => { const handleStartWork = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
const data = await api.pomodoroStart(duration, taskNote); await api.pomodoroStart(duration, taskNote);
setStatus(data); await refresh();
await loadHistory();
setResult(""); setResult("");
setCompleted(false); setCompleted(false);
} catch (err) { } catch (err) {
@@ -49,7 +49,8 @@ export default function Pomodoro() {
const handlePause = async () => { const handlePause = async () => {
setError(""); setError("");
try { try {
setStatus(await api.pomodoroPause()); await api.pomodoroPause();
await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Ошибка"); setError(err instanceof Error ? err.message : "Ошибка");
} }
@@ -58,7 +59,8 @@ export default function Pomodoro() {
const handleResume = async () => { const handleResume = async () => {
setError(""); setError("");
try { try {
setStatus(await api.pomodoroResume()); await api.pomodoroResume();
await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Ошибка"); setError(err instanceof Error ? err.message : "Ошибка");
} }
@@ -69,6 +71,7 @@ export default function Pomodoro() {
try { try {
await api.pomodoroStop(result, completed); await api.pomodoroStop(result, completed);
await refresh(); await refresh();
await loadHistory();
setResult(""); setResult("");
setCompleted(false); setCompleted(false);
} catch (err) { } catch (err) {
@@ -81,6 +84,7 @@ export default function Pomodoro() {
try { try {
await api.pomodoroSkip(); await api.pomodoroSkip();
await refresh(); await refresh();
await loadHistory();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Ошибка"); setError(err instanceof Error ? err.message : "Ошибка");
} }
@@ -91,6 +95,7 @@ export default function Pomodoro() {
try { try {
await api.pomodoroResetCycle(false); await api.pomodoroResetCycle(false);
await refresh(); await refresh();
await loadHistory();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Ошибка"); 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 ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0; : 0;
const cycle = status?.cycle; const cycle = status?.cycle;
const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive);
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f"; const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
return ( return (
@@ -109,7 +115,7 @@ export default function Pomodoro() {
<section className="timer-card"> <section className="timer-card">
{cycle && ( {cycle && (
<div className="cycle-badge"> <div className="cycle-badge">
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} Цикл {cycleLabel}
{cycle.auto_advance && " · авто"} {cycle.auto_advance && " · авто"}
</div> </div>
)} )}
@@ -152,10 +158,16 @@ export default function Pomodoro() {
<button type="submit" className="primary-btn"> <button type="submit" className="primary-btn">
Старт работы Старт работы
</button> </button>
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}> <button
type="button"
onClick={() => api.pomodoroStartShortBreak().then(() => refresh())}
>
Короткий перерыв Короткий перерыв
</button> </button>
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}> <button
type="button"
onClick={() => api.pomodoroStartLongBreak().then(() => refresh())}
>
Длинный перерыв Длинный перерыв
</button> </button>
</div> </div>
+27
View File
@@ -1,3 +1,5 @@
import { PomodoroCycle } from "../api/client";
export const PHASE_LABELS: Record<string, string> = { export const PHASE_LABELS: Record<string, string> = {
work: "Работа", work: "Работа",
short_break: "Перерыв", short_break: "Перерыв",
@@ -7,3 +9,28 @@ export const PHASE_LABELS: Record<string, string> = {
export function phaseLabel(phase: string): string { export function phaseLabel(phase: string): string {
return PHASE_LABELS[phase] ?? phase; 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}`;
}
+1 -1
View File
@@ -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"} {"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"}