Taiga integration
This commit is contained in:
+15
-7
@@ -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)
|
||||||
|
|||||||
@@ -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 для документов
|
||||||
- Проактивные чаты по расписанию
|
- Проактивные чаты по расписанию
|
||||||
- Фитнес-трекер
|
- Фитнес-трекер
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.projects.service import ProjectService
|
||||||
|
|
||||||
|
__all__ = ["ProjectService"]
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in New Issue
Block a user