From 4c260b1bad328102dcefb115921afb67841fd777 Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Tue, 10 Mar 2026 05:35:01 +0300 Subject: [PATCH] [AI] Youtrack integration --- .env.example | 3 ++ .gitignore | 4 +- pyproject.toml | 4 +- src/time_tracker/app.py | 43 ++++++++++++++- src/time_tracker/youtrack_client.py | 81 +++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 src/time_tracker/youtrack_client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4a552bc --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +YOUTRACK_URL=https://your-company.myjetbrains.com/youtrack +YOUTRACK_TOKEN=perm:abc123... +# YOUTRACK_CA_BUNDLE=certs/your-ca.pem \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1472ae5..6116e43 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,6 @@ Icon? # Configuration and data files generated by the application config.json tasks/ -*.json \ No newline at end of file +*.json + +.env diff --git a/pyproject.toml b/pyproject.toml index 23a276d..2a2996c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "pynput>=1.7.6", "pywin32>=306; sys_platform == 'win32'", "psutil>=5.9.0; sys_platform == 'win32'", - "pydantic>=2.0.0", # <-- добавлено для работы с моделями и JSON + "pydantic>=2.0.0", + "python-dotenv>=1.0.0", # <-- новое + "httpx>=0.25.0", # для асинхронных/синхронных HTTP-запросов ] [project.optional-dependencies] dev = [ diff --git a/src/time_tracker/app.py b/src/time_tracker/app.py index 98ca1da..c2cfefb 100644 --- a/src/time_tracker/app.py +++ b/src/time_tracker/app.py @@ -9,6 +9,13 @@ from pynput import mouse, keyboard from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession from .utils import get_platform, ensure_dir +from dotenv import load_dotenv +import os + +from .youtrack_client import YouTrackClient + +if not load_dotenv(".env"): + load_dotenv(".env.example") class Application: def __init__(self): @@ -152,7 +159,12 @@ class Application: def maybe_save_task(self): """Сохраняет текущую задачу, если она есть""" + if self.current_task: + try: + self.sync_with_youtrack() + except: + pass self.save_task(self.current_task) def toggle_tracking(self): @@ -267,8 +279,35 @@ class Application: def shutdown(self): self._running = False - self.maybe_save_task() # ← сохраняем перед выходом + self.maybe_save_task() if hasattr(self, 'mouse_listener'): self.mouse_listener.stop() if hasattr(self, 'keyboard_listener'): - self.keyboard_listener.stop() \ No newline at end of file + self.keyboard_listener.stop() + + def sync_with_youtrack(self): + """Синхронизирует все несинхронизированные сессии текущей задачи с YouTrack""" + if not self.current_task: + return + + client = YouTrackClient() + if not client.enabled: + return + + # Проверяем, существует ли задача + if not client.issue_exists(self.current_task.task_id): + return + + changed = False + for session in self.current_task.sessions: + if not session.synchronized: + total_minutes = sum(detail.duration_seconds for detail in session.details) / 60.0 + if client.add_work_item(self.current_task.task_id, total_minutes): + session.synchronized = True + changed = True + else: + # Если ошибка — не помечаем как синхронизированную (повторим позже) + pass + + if changed: + self.save_task(self.current_task) \ No newline at end of file diff --git a/src/time_tracker/youtrack_client.py b/src/time_tracker/youtrack_client.py new file mode 100644 index 0000000..4f21469 --- /dev/null +++ b/src/time_tracker/youtrack_client.py @@ -0,0 +1,81 @@ +import httpx +import os +import logging +from pathlib import Path + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger("YouTrackClient") + + +class YouTrackClient: + def __init__(self): + self.base_url = os.getenv("YOUTRACK_URL") + self.token = os.getenv("YOUTRACK_TOKEN") + self.ca_bundle = os.getenv("YOUTRACK_CA_BUNDLE") + + if not self.base_url or not self.token: + self.enabled = False + logger.info("YouTrack: .env не настроен — синхронизация отключена") + return + + # Определяем verify параметр для httpx + if self.ca_bundle: + ca_path = Path(self.ca_bundle) + if ca_path.exists(): + self.verify = str(ca_path.resolve()) + logger.info(f"YouTrack: использую CA bundle: {self.verify}") + else: + logger.error(f"YouTrack: CA bundle не найден: {ca_path}") + self.verify = True # fallback на системные + else: + self.verify = True # стандартное поведение + + self.enabled = True + self.headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "Accept": "application/json" + } + self.base_url = self.base_url.rstrip('/') + + def _get_client(self) -> httpx.Client: + """Создаёт HTTP-клиент с правильной настройкой SSL""" + return httpx.Client(verify=self.verify, timeout=15) + + def issue_exists(self, issue_id: str) -> bool: + if not self.enabled: + return False + try: + with self._get_client() as client: + url = f"{self.base_url}/api/issues/{issue_id}" + resp = client.get(url, headers=self.headers) + exists = resp.status_code == 200 + if not exists: + logger.warning(f"YouTrack: задача {issue_id} не найдена (HTTP {resp.status_code})") + return exists + except Exception as e: + logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}") + return False + + def add_work_item(self, issue_id: str, minutes: float) -> bool: + if not self.enabled: + return False + + if minutes <= 0: + return True + + payload = {"duration": {"minutes": round(minutes)}} + + try: + with self._get_client() as client: + url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems" + resp = client.post(url, headers=self.headers, json=payload) + if resp.status_code in (200, 201): + logger.info(f"YouTrack: +{round(minutes)} мин к {issue_id}") + return True + else: + logger.error(f"YouTrack: ошибка отправки времени ({issue_id}): {resp.status_code} – {resp.text}") + return False + except Exception as e: + logger.error(f"YouTrack: сетевая ошибка при отправке времени ({issue_id}): {e}") + return False \ No newline at end of file