[AI] Youtrack integration
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
YOUTRACK_URL=https://your-company.myjetbrains.com/youtrack
|
||||||
|
YOUTRACK_TOKEN=perm:abc123...
|
||||||
|
# YOUTRACK_CA_BUNDLE=certs/your-ca.pem
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -87,3 +87,5 @@ Icon?
|
|||||||
config.json
|
config.json
|
||||||
tasks/
|
tasks/
|
||||||
*.json
|
*.json
|
||||||
|
|
||||||
|
.env
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ dependencies = [
|
|||||||
"pynput>=1.7.6",
|
"pynput>=1.7.6",
|
||||||
"pywin32>=306; sys_platform == 'win32'",
|
"pywin32>=306; sys_platform == 'win32'",
|
||||||
"psutil>=5.9.0; 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]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ from pynput import mouse, keyboard
|
|||||||
from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession
|
from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession
|
||||||
from .utils import get_platform, ensure_dir
|
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:
|
class Application:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -152,7 +159,12 @@ class Application:
|
|||||||
|
|
||||||
def maybe_save_task(self):
|
def maybe_save_task(self):
|
||||||
"""Сохраняет текущую задачу, если она есть"""
|
"""Сохраняет текущую задачу, если она есть"""
|
||||||
|
|
||||||
if self.current_task:
|
if self.current_task:
|
||||||
|
try:
|
||||||
|
self.sync_with_youtrack()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
self.save_task(self.current_task)
|
self.save_task(self.current_task)
|
||||||
|
|
||||||
def toggle_tracking(self):
|
def toggle_tracking(self):
|
||||||
@@ -267,8 +279,35 @@ class Application:
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
self.maybe_save_task() # ← сохраняем перед выходом
|
self.maybe_save_task()
|
||||||
if hasattr(self, 'mouse_listener'):
|
if hasattr(self, 'mouse_listener'):
|
||||||
self.mouse_listener.stop()
|
self.mouse_listener.stop()
|
||||||
if hasattr(self, 'keyboard_listener'):
|
if hasattr(self, 'keyboard_listener'):
|
||||||
self.keyboard_listener.stop()
|
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)
|
||||||
81
src/time_tracker/youtrack_client.py
Normal file
81
src/time_tracker/youtrack_client.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user