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}"