Taiga integration
This commit is contained in:
+15
-7
@@ -15,16 +15,24 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# App
|
||||
DATABASE_URL=sqlite:///./data/assistant.db
|
||||
# Add your server URL with FRONTEND_PORT, e.g. http://grigosserver:3080
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
|
||||
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
||||
|
||||
# External services (phase 2 — homelab integrations)
|
||||
TAIGA_BASE_URL=http://taiga:9000
|
||||
TAIGA_PORT=9000
|
||||
GITEA_BASE_URL=http://gitea:3000
|
||||
GITEA_PORT=3000
|
||||
GITEA_SSH_PORT=222
|
||||
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||
TAIGA_USERNAME=your_taiga_user
|
||||
TAIGA_PASSWORD=your_taiga_password
|
||||
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
|
||||
|
||||
# Gitea (on host :3000, nginx → git.grigowashere.ru)
|
||||
GITEA_BASE_URL=http://host.docker.internal:3000
|
||||
GITEA_TOKEN=your_gitea_api_token
|
||||
GITEA_PUBLIC_URL=https://git.grigowashere.ru
|
||||
GITEA_WEBHOOK_SECRET=generate_a_random_secret
|
||||
|
||||
# Gitea webhook URL (configure in repo settings):
|
||||
# http://127.0.0.1:8080/api/v1/webhooks/gitea
|
||||
|
||||
REPOS_DIR=/data/repos
|
||||
|
||||
# Vector DB (phase 3)
|
||||
|
||||
@@ -87,6 +87,70 @@ Vite dev-server: http://localhost:5173 (проксирует `/api` на backend
|
||||
| POST | `/api/v1/pomodoro/resume` | Продолжить |
|
||||
| POST | `/api/v1/pomodoro/stop` | Стоп `{result, completed}` |
|
||||
| GET | `/api/v1/pomodoro/history` | История сессий |
|
||||
| GET | `/api/v1/projects` | Проекты Taiga + привязка Gitea |
|
||||
| POST | `/api/v1/projects/sync-taiga` | Синхронизировать проекты из Taiga |
|
||||
| PUT | `/api/v1/projects/{slug}/gitea` | Привязать Gitea repo |
|
||||
| POST | `/api/v1/work-items` | Создать фичу/баг → Taiga + Gitea |
|
||||
| GET | `/api/v1/work-items` | Список work items |
|
||||
| POST | `/api/v1/webhooks/gitea` | Webhook для автозакрытия по push |
|
||||
|
||||
## Taiga + Gitea (фаза 2)
|
||||
|
||||
Taiga и Gitea работают **на хосте** (не в Docker):
|
||||
- Taiga: `127.0.0.1:9000` → `taiga.grigowashere.ru`
|
||||
- Gitea: `127.0.0.1:3000` → `git.grigowashere.ru`
|
||||
|
||||
Контейнер backend достучится через `host.docker.internal` (настроено в `docker-compose.yml`).
|
||||
|
||||
### Настройка `.env`
|
||||
|
||||
```env
|
||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||
TAIGA_USERNAME=...
|
||||
TAIGA_PASSWORD=...
|
||||
TAIGA_PUBLIC_URL=https://taiga.grigowashere.ru
|
||||
|
||||
GITEA_BASE_URL=http://host.docker.internal:3000
|
||||
GITEA_TOKEN=... # Settings → Applications → Generate Token
|
||||
GITEA_PUBLIC_URL=https://git.grigowashere.ru
|
||||
GITEA_WEBHOOK_SECRET=... # произвольная строка
|
||||
```
|
||||
|
||||
### Первый запуск
|
||||
|
||||
```bash
|
||||
# 1. Синхронизировать проекты Taiga (ID подтянутся автоматически)
|
||||
curl -X POST http://localhost:8080/api/v1/projects/sync-taiga
|
||||
|
||||
# 2. Привязать Gitea repo к проекту Taiga
|
||||
curl -X PUT http://localhost:8080/api/v1/projects/home-assistant/gitea \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"gitea_owner":"Grigo","gitea_repo":"Home_assistant","default_branch":"main"}'
|
||||
```
|
||||
|
||||
### Gitea webhook
|
||||
|
||||
В репозитории: **Settings → Webhooks → Add Webhook**:
|
||||
|
||||
- URL: `http://127.0.0.1:8080/api/v1/webhooks/gitea`
|
||||
- Content type: `application/json`
|
||||
- Secret: значение `GITEA_WEBHOOK_SECRET`
|
||||
- Events: **Push**
|
||||
|
||||
### Автозакрытие по коммиту
|
||||
|
||||
В сообщении коммита:
|
||||
|
||||
```
|
||||
fix: кнопка сохранения
|
||||
Closes gitea #12, taiga #45
|
||||
```
|
||||
|
||||
Закроются Gitea issue #12 и Taiga story #45 (если только один ref — второй найдётся по связи в БД).
|
||||
|
||||
### Чат
|
||||
|
||||
«Заведи баг: кнопка не сохраняет настройки» → `create_work_item` → Taiga story + Gitea issue + ветка `feature/45-...`.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -98,7 +162,6 @@ data/ SQLite БД (создаётся автоматически)
|
||||
|
||||
## Следующие фазы
|
||||
|
||||
- Интеграция Taiga + Gitea (project-agent внутри проекта)
|
||||
- RAG с Qdrant для документов
|
||||
- Проактивные чаты по расписанию
|
||||
- Фитнес-трекер
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import character, chat, health, pomodoro
|
||||
from app.api.routes import character, chat, health, pomodoro, projects, webhooks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||
api_router.include_router(character.router, tags=["character"])
|
||||
api_router.include_router(projects.router, tags=["projects"])
|
||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||
|
||||
@@ -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.
|
||||
- reset_pomodoro_cycle — только когда пользователь явно просит сбросить цикл.
|
||||
- Задачи: sync_taiga_projects, list_taiga_projects, create_work_item, list_work_items.
|
||||
- create_work_item — при «заведи баг/фичу», «добавь в таигу»; передай полный текст пользователя.
|
||||
- Список проектов и открытых задач уже в контексте — не выдумывай, при необходимости уточни tool-вызовом.
|
||||
""".strip()
|
||||
|
||||
DEFAULT_CARD: dict[str, Any] = {
|
||||
|
||||
@@ -67,9 +67,63 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
if tool_name == "get_pomodoro_history":
|
||||
return _format_history_notice(data)
|
||||
|
||||
if tool_name == "create_work_item":
|
||||
return _format_work_item_notice(data)
|
||||
|
||||
if tool_name == "list_work_items":
|
||||
return _format_work_items_list_notice(data)
|
||||
|
||||
if tool_name == "sync_taiga_projects":
|
||||
return f"📋 Синхронизировано проектов Taiga: **{len(data)}**"
|
||||
|
||||
if tool_name == "list_taiga_projects":
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Проекты Taiga не найдены. Вызовите sync_taiga_projects."
|
||||
lines = ["📋 **Проекты:**"]
|
||||
for p in data:
|
||||
gitea = f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" if p.get("gitea_configured") else "—"
|
||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
||||
return "\n".join(lines)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_work_item_notice(data: dict[str, Any]) -> str | None:
|
||||
if data.get("error"):
|
||||
return f"📋 {data['error']}"
|
||||
if not data.get("ok"):
|
||||
return None
|
||||
taiga = data.get("taiga", {})
|
||||
gitea = data.get("gitea", {})
|
||||
lines = [
|
||||
"📋 **Создано:**",
|
||||
f"- Taiga: #{taiga.get('ref')} — {taiga.get('subject')}",
|
||||
f"- URL: {taiga.get('url')}",
|
||||
]
|
||||
if gitea.get("url"):
|
||||
lines.append(f"- Gitea: {gitea.get('url')}")
|
||||
if data.get("branch"):
|
||||
lines.append(f"- Ветка: `{data['branch']}`")
|
||||
subtasks = data.get("subtasks") or []
|
||||
if subtasks:
|
||||
lines.append("**Подзадачи:**")
|
||||
for t in subtasks:
|
||||
lines.append(f"- #{t.get('ref')} {t.get('subject')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_work_items_list_notice(data: Any) -> str | None:
|
||||
if not isinstance(data, list) or not data:
|
||||
return "📋 Work items не найдены."
|
||||
lines = ["📋 **Work items:**"]
|
||||
for item in data[:15]:
|
||||
lines.append(
|
||||
f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} "
|
||||
f"({item.get('taiga_slug')})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_status_notice(data: dict[str, Any]) -> str:
|
||||
status = data.get("status", "idle")
|
||||
phase = data.get("phase", PHASE_WORK)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notices import format_pomodoro_context, format_pomodoro_notice
|
||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||
from app.db.models import ChatSession, Message
|
||||
from app.llm.client import LLMClient
|
||||
from app.pomodoro.service import PomodoroService
|
||||
@@ -45,9 +46,11 @@ class ChatService:
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
status = PomodoroService(self.db).get_status()
|
||||
projects_snapshot = get_projects_snapshot(self.db)
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
f"{format_pomodoro_context(status)}"
|
||||
f"{format_pomodoro_context(status)}\n\n"
|
||||
f"{format_projects_context(projects_snapshot)}"
|
||||
)
|
||||
|
||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||
@@ -129,7 +132,7 @@ class ChatService:
|
||||
for tool_call in tool_calls:
|
||||
fn = tool_call["function"]
|
||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||
result = execute_tool(self.db, fn["name"], args)
|
||||
result = await execute_tool(self.db, fn["name"], args)
|
||||
tool_message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
|
||||
@@ -22,10 +22,31 @@ class Settings(BaseSettings):
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000"
|
||||
system_prompt_path: str = "./prompts/assistant.md"
|
||||
|
||||
# Taiga/Gitea on host (not in Docker) — use host.docker.internal from container
|
||||
taiga_base_url: str = "http://host.docker.internal:9000"
|
||||
taiga_username: str = ""
|
||||
taiga_password: str = ""
|
||||
taiga_public_url: str = "https://taiga.grigowashere.ru"
|
||||
|
||||
gitea_base_url: str = "http://host.docker.internal:3000"
|
||||
gitea_token: str = ""
|
||||
gitea_public_url: str = "https://git.grigowashere.ru"
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
repos_dir: str = "/data/repos"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def taiga_configured(self) -> bool:
|
||||
return bool(self.taiga_username and self.taiga_password)
|
||||
|
||||
@property
|
||||
def gitea_configured(self) -> bool:
|
||||
return bool(self.gitea_token)
|
||||
|
||||
def load_system_prompt(self) -> str:
|
||||
path = Path(self.system_prompt_path)
|
||||
if path.is_file():
|
||||
|
||||
@@ -68,3 +68,45 @@ class PomodoroSession(Base):
|
||||
elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TaigaProject(Base):
|
||||
__tablename__ = "taiga_projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_id: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectBinding(Base):
|
||||
__tablename__ = "project_bindings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
default_branch: Mapped[str] = mapped_column(String(64), default="main")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
taiga_slug: Mapped[str] = mapped_column(String(255), index=True)
|
||||
taiga_project_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_id: Mapped[int] = mapped_column(Integer)
|
||||
taiga_story_ref: Mapped[int] = mapped_column(Integer, index=True)
|
||||
gitea_owner: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_repo: Mapped[str] = mapped_column(String(255), default="")
|
||||
gitea_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
suggested_branch: Mapped[str] = mapped_column(String(255), default="")
|
||||
raw_text: Mapped[str] = mapped_column(Text, default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -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 app.pomodoro.service import PomodoroService
|
||||
from app.projects.service import ProjectService
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
@@ -104,7 +105,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_pomodoro_history",
|
||||
"description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.",
|
||||
"description": "История помидоро-сессий (таймер), не Taiga-задачи.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -114,44 +115,107 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "sync_taiga_projects",
|
||||
"description": "Синхронизировать список проектов из Taiga API. Вызывай если проекты неизвестны.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_taiga_projects",
|
||||
"description": "Список проектов Taiga с привязкой Gitea.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_work_item",
|
||||
"description": (
|
||||
"Создать фичу/баг из вольного текста: структурировать через LLM, "
|
||||
"создать Taiga story + Gitea issue. Вызывай при «заведи баг», «оформи фичу», «добавь в таигу»."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Полное описание от пользователя"},
|
||||
"project_slug": {
|
||||
"type": "string",
|
||||
"description": "Slug проекта Taiga, если известен",
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_work_items",
|
||||
"description": "Список созданных work items (Taiga + Gitea связки).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "description": "open или closed"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
|
||||
service = PomodoroService(db)
|
||||
async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
|
||||
pomodoro = PomodoroService(db)
|
||||
projects = ProjectService(db)
|
||||
|
||||
try:
|
||||
if name == "get_pomodoro_status":
|
||||
result = service.get_status()
|
||||
result = pomodoro.get_status()
|
||||
elif name == "start_pomodoro":
|
||||
result = service.start_work(
|
||||
result = pomodoro.start_work(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
task_note=arguments.get("task_note", ""),
|
||||
)
|
||||
elif name == "start_short_break":
|
||||
result = service.start_short_break(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
)
|
||||
result = pomodoro.start_short_break(duration_min=arguments.get("duration_min"))
|
||||
elif name == "start_long_break":
|
||||
result = service.start_long_break(
|
||||
duration_min=arguments.get("duration_min"),
|
||||
)
|
||||
result = pomodoro.start_long_break(duration_min=arguments.get("duration_min"))
|
||||
elif name == "stop_pomodoro":
|
||||
result = service.stop(
|
||||
result = pomodoro.stop(
|
||||
result=arguments.get("result", ""),
|
||||
completed=arguments.get("completed", False),
|
||||
)
|
||||
elif name == "skip_pomodoro_phase":
|
||||
result = service.skip_phase()
|
||||
result = pomodoro.skip_phase()
|
||||
elif name == "reset_pomodoro_cycle":
|
||||
result = service.reset_cycle(
|
||||
clear_task=arguments.get("clear_task", False),
|
||||
)
|
||||
result = pomodoro.reset_cycle(clear_task=arguments.get("clear_task", False))
|
||||
elif name == "get_pomodoro_history":
|
||||
result = service.history(limit=arguments.get("limit", 10))
|
||||
result = pomodoro.history(limit=arguments.get("limit", 10))
|
||||
elif name == "sync_taiga_projects":
|
||||
result = projects.sync_taiga_projects()
|
||||
elif name == "list_taiga_projects":
|
||||
result = projects.list_projects()
|
||||
elif name == "create_work_item":
|
||||
result = await projects.create_work_item(
|
||||
arguments.get("text", ""),
|
||||
project_slug=arguments.get("project_slug"),
|
||||
)
|
||||
elif name == "list_work_items":
|
||||
result = projects.list_work_items(
|
||||
limit=arguments.get("limit", 20),
|
||||
status=arguments.get("status"),
|
||||
)
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except ValueError as exc:
|
||||
return json.dumps({"error": str(exc)}, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc)}, ensure_ascii=False)
|
||||
|
||||
@@ -5,3 +5,4 @@ pydantic-settings>=2.6.0
|
||||
openai>=1.55.0
|
||||
python-dotenv>=1.0.1
|
||||
aiosqlite>=0.20.0
|
||||
httpx>=0.28.0
|
||||
|
||||
@@ -6,6 +6,8 @@ services:
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import PomodoroWidget from "./components/PomodoroWidget";
|
||||
import { PomodoroProvider } from "./context/PomodoroContext";
|
||||
import Character from "./pages/Character";
|
||||
import Chat from "./pages/Chat";
|
||||
import Pomodoro from "./pages/Pomodoro";
|
||||
@@ -7,6 +8,7 @@ import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<PomodoroProvider>
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Home AI Assistant</h1>
|
||||
@@ -27,5 +29,6 @@ export default function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</PomodoroProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,22 +9,43 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pomodoro-widget:hover {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact:hover {
|
||||
border-color: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pomodoro-widget-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-body {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget-ring {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.pomodoro-widget-inner {
|
||||
@@ -39,8 +60,8 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-inner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.pomodoro-widget-time {
|
||||
@@ -50,7 +71,7 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-time {
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget-label {
|
||||
@@ -64,14 +85,20 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget-cycle {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #c5ccd6;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-cycle {
|
||||
font-size: 0.75rem;
|
||||
color: #8b95a5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget-task {
|
||||
margin: 0.25rem 0 0;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a8b0bd;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import { formatTime } from "../utils/time";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
|
||||
import "./PomodoroWidget.css";
|
||||
|
||||
interface PomodoroWidgetProps {
|
||||
@@ -19,30 +19,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status.cycle;
|
||||
const cycleLabel = formatCycleLabel(cycle, status.phase, isActive);
|
||||
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
|
||||
<div
|
||||
className="pomodoro-widget-ring"
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="pomodoro-widget-inner">
|
||||
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||
<span className="pomodoro-widget-label">
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
<div className="pomodoro-widget-body">
|
||||
<div
|
||||
className="pomodoro-widget-ring"
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="pomodoro-widget-inner">
|
||||
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||
<span className="pomodoro-widget-label">
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="pomodoro-widget-cycle" title="Прогресс цикла">
|
||||
{cycleLabel}
|
||||
</span>
|
||||
</div>
|
||||
{!compact && (
|
||||
<>
|
||||
{cycle && (
|
||||
<p className="pomodoro-widget-cycle">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
</p>
|
||||
)}
|
||||
{status.task_note && <p className="pomodoro-widget-task">{status.task_note}</p>}
|
||||
</>
|
||||
|
||||
{!compact && status.task_note && (
|
||||
<p className="pomodoro-widget-task">{status.task_note}</p>
|
||||
)}
|
||||
</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 { api, ChatMessage, ChatSession } from "../api/client";
|
||||
import PomodoroWidget from "../components/PomodoroWidget";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import "./Chat.css";
|
||||
|
||||
function shouldShowMessage(msg: ChatMessage): boolean {
|
||||
@@ -58,11 +58,14 @@ export default function Chat() {
|
||||
|
||||
useEffect(() => {
|
||||
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
|
||||
if (seq > lastNotifySeq && activeId) {
|
||||
if (seq > lastNotifySeq) {
|
||||
setLastNotifySeq(seq);
|
||||
loadMessages(activeId).catch(console.error);
|
||||
refreshPomodoro().catch(console.error);
|
||||
if (activeId) {
|
||||
loadMessages(activeId).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]);
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await api.createSession();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { api, PomodoroHistoryItem } from "../api/client";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import "./Pomodoro.css";
|
||||
|
||||
export default function Pomodoro() {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const { status, refresh } = usePomodoro();
|
||||
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
|
||||
const [duration, setDuration] = useState(25);
|
||||
const [taskNote, setTaskNote] = useState("");
|
||||
@@ -13,32 +14,31 @@ export default function Pomodoro() {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = async () => {
|
||||
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
|
||||
setStatus(current);
|
||||
const loadHistory = async () => {
|
||||
const past = await api.pomodoroHistory();
|
||||
setHistory(past);
|
||||
if (current.cycle?.work_duration_min) {
|
||||
setDuration(current.cycle.work_duration_min);
|
||||
}
|
||||
if (current.cycle?.task_note) {
|
||||
setTaskNote(current.cycle.task_note);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(console.error);
|
||||
const timer = setInterval(() => {
|
||||
api.pomodoroStatus().then(setStatus).catch(console.error);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
loadHistory().catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.cycle?.work_duration_min) {
|
||||
setDuration(status.cycle.work_duration_min);
|
||||
}
|
||||
if (status?.cycle?.task_note) {
|
||||
setTaskNote(status.cycle.task_note);
|
||||
}
|
||||
}, [status?.cycle?.work_duration_min, status?.cycle?.task_note]);
|
||||
|
||||
const handleStartWork = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.pomodoroStart(duration, taskNote);
|
||||
setStatus(data);
|
||||
await api.pomodoroStart(duration, taskNote);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -49,7 +49,8 @@ export default function Pomodoro() {
|
||||
const handlePause = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroPause());
|
||||
await api.pomodoroPause();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -58,7 +59,8 @@ export default function Pomodoro() {
|
||||
const handleResume = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroResume());
|
||||
await api.pomodoroResume();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroStop(result, completed);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -81,6 +84,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroSkip();
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -91,6 +95,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroResetCycle(false);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -102,6 +107,7 @@ export default function Pomodoro() {
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status?.cycle;
|
||||
const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive);
|
||||
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
@@ -109,7 +115,7 @@ export default function Pomodoro() {
|
||||
<section className="timer-card">
|
||||
{cycle && (
|
||||
<div className="cycle-badge">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
Цикл {cycleLabel}
|
||||
{cycle.auto_advance && " · авто"}
|
||||
</div>
|
||||
)}
|
||||
@@ -152,10 +158,16 @@ export default function Pomodoro() {
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт работы
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => api.pomodoroStartShortBreak().then(() => refresh())}
|
||||
>
|
||||
Короткий перерыв
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => api.pomodoroStartLongBreak().then(() => refresh())}
|
||||
>
|
||||
Длинный перерыв
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PomodoroCycle } from "../api/client";
|
||||
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
work: "Работа",
|
||||
short_break: "Перерыв",
|
||||
@@ -7,3 +9,28 @@ export const PHASE_LABELS: Record<string, string> = {
|
||||
export function phaseLabel(phase: string): string {
|
||||
return PHASE_LABELS[phase] ?? phase;
|
||||
}
|
||||
|
||||
/** Текущий номер помидоро в цикле (1..N), а не только завершённые. */
|
||||
export function cycleProgress(
|
||||
cycle: PomodoroCycle | undefined,
|
||||
phase: string,
|
||||
isActive: boolean
|
||||
): { current: number; total: number } {
|
||||
const total = cycle?.sessions_until_long_break ?? 4;
|
||||
let current = cycle?.completed_work_sessions ?? 0;
|
||||
|
||||
if (isActive && phase === "work") {
|
||||
current += 1;
|
||||
}
|
||||
|
||||
return { current, total };
|
||||
}
|
||||
|
||||
export function formatCycleLabel(
|
||||
cycle: PomodoroCycle | undefined,
|
||||
phase: string,
|
||||
isActive: boolean
|
||||
): string {
|
||||
const { current, total } = cycleProgress(cycle, phase, isActive);
|
||||
return `${current}/${total}`;
|
||||
}
|
||||
|
||||
@@ -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