Files
redmine-reporter/redmine_reporter/client.py
Артём Кокос 58fa5a7ab4 Add retry/backoff, timeout, and issue_id chunking to Redmine client
- #24: Mount HTTPAdapter with Retry(total=3, backoff_factor=0.5) for
  HTTP 429/500/502/503/504 on the Redmine session, set 30s timeout
- #21: Split large issue_id lists into chunks of 100 to avoid exceeding
  URL length limits on reverse proxies

Closes #24, #21
2026-06-27 13:06:41 +07:00

114 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, Dict, List, Optional, Tuple
import requests
from redminelib import Redmine
from redminelib.resources import Issue
from urllib3.util.retry import Retry
from .config import Config
# Таймаут на один HTTP-запрос к Redmine (секунды).
REQUEST_TIMEOUT = 30
# Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21).
ISSUE_ID_CHUNK_SIZE = 100
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key()
if api_key:
return {"key": api_key}
return {
"username": Config.get_redmine_user(),
"password": Config.get_redmine_password(),
}
def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
"""Создаёт HTTPAdapter с retry для временных ошибок (#24)."""
retry = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"],
)
return requests.adapters.HTTPAdapter(max_retries=retry)
def _create_redmine() -> Redmine:
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
redmine = Redmine(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": Config.get_redmine_verify(),
"timeout": REQUEST_TIMEOUT,
},
)
# Монтируем retry-адаптер на сессию для автоматических повторов
retry_adapter = _make_retry_adapter()
redmine.session.mount("https://", retry_adapter)
redmine.session.mount("http://", retry_adapter)
return redmine
def _fetch_issues_chunked(
redmine: Redmine, issue_ids: List[int]
) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk)
issues = redmine.issue.filter(
issue_id=issue_list_str, status_id="*", sort="project:asc"
)
all_issues.extend(issues)
return all_issues
def fetch_issues_with_spent_time(
from_date: str, to_date: str
) -> Optional[List[Tuple[Issue, float]]]:
"""
Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue.
Returns list of (issue, total_hours) tuples.
"""
redmine = _create_redmine()
current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter(
user_id=current_user.id, from_date=from_date, to_date=to_date
)
# Агрегируем часы по issue.id
spent_time: Dict[int, float] = {}
issue_ids = set()
for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
iid = entry.issue.id
issue_ids.add(iid)
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
if not issue_ids:
return None
# Загружаем полные объекты задач чанками (#21)
sorted_ids = sorted(issue_ids)
issues = _fetch_issues_chunked(redmine, sorted_ids)
# Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть.
result = []
for issue in issues:
total_hours = spent_time.get(issue.id, 0.0)
result.append((issue, total_hours))
return result