Files
redmine-reporter/redmine_reporter/client.py
Кокос Артем Николаевич 5d1cf4ab74 feat: add --my-tasks mode for open assigned issues (#71)
List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
2026-10-28 15:26:41 +07:00

400 lines
16 KiB
Python
Raw Permalink 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.
import sys
from datetime import date
from typing import Any
import requests
import truststore
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
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
class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Exception | None = None):
super().__init__(message)
self.message = message
self.original = original
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).
При verify=True подключает системное хранилище CA ОС через
truststore.inject_into_ssl() (#62).
"""
verify = Config.get_redmine_verify()
if verify is True:
# verify_ssl: true — проверка по системному хранилищу CA ОС (truststore),
# а не по certifi: корпоративные CA из ОС продолжают работать (#62).
truststore.inject_into_ssl()
redmine = Redmine(
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests={
"verify": verify,
"timeout": REQUEST_TIMEOUT,
},
)
# Монтируем retry-адаптер на сессию для автоматических повторов.
# В python-redmine сессия живёт в engine, а redmine.session — контекстный менеджер.
retry_adapter = _make_retry_adapter()
redmine.engine.session.mount("https://", retry_adapter)
redmine.engine.session.mount("http://", retry_adapter)
return redmine
def _format_redmine_error(exc: Exception) -> str:
"""Преобразует исключение Redmine/requests в понятное сообщение."""
if isinstance(exc, AuthError):
return (
"Authentication failed: invalid API key, login or password. "
"Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD."
)
if isinstance(exc, ForbiddenError):
return (
"Access denied: your Redmine account does not have permission "
"to read time entries or issues."
)
if isinstance(exc, ResourceNotFoundError):
return "Requested Redmine resource not found: check user/project identifiers."
# requests HTTPError может быть обёрнуто в python-redmine
original = getattr(exc, "response", None)
if original is None:
original = exc
response = getattr(original, "response", None)
if response is not None and hasattr(response, "status_code"):
status = response.status_code
if status == 401:
return "Authentication failed (HTTP 401): check your API key or login/password."
if status == 403:
return "Access denied (HTTP 403): insufficient Redmine permissions."
if status == 404:
return "Redmine endpoint not found (HTTP 404): check REDMINE_URL."
if status == 429:
return "Too many requests (HTTP 429): Redmine rate limit exceeded."
if 500 <= status < 600:
return f"Redmine server error (HTTP {status}): try again later."
return f"Redmine API returned HTTP {status}."
if isinstance(exc, requests.exceptions.Timeout):
return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds."
if isinstance(exc, requests.exceptions.ConnectionError):
return "Cannot connect to Redmine: check the URL and network."
if isinstance(exc, requests.exceptions.RequestException):
return f"Network error while calling Redmine: {exc}"
return str(exc)
def _load_time_entry_activities(redmine: Redmine) -> dict[int, str]:
"""Загружает справочник типов активности time entries.
Возвращает словарь id -> name. Если справочник недоступен,
возвращает пустой словарь — тогда будем использовать данные из самих entries.
"""
try:
activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities}
except Exception as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries.
print(
f"⚠️ Could not load time entry activities: {exc}. "
"Activity names will be taken from time entries.",
file=sys.stderr,
)
return {}
def _get_activity_name(entry, activities: dict[int, str]) -> str:
"""Определяет название активности для time entry."""
activity = getattr(entry, "activity", None)
if activity is None:
return "<N/A>"
# activity может быть объектом с id/name или просто значением
activity_id = getattr(activity, "id", None)
if activity_id is not None:
name = activities.get(int(activity_id))
if name:
return name
activity_name = getattr(activity, "name", None)
if activity_name:
return str(activity_name)
return str(activity_id)
return str(activity)
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 _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int:
"""Преобразует строковый идентификатор пользователя в числовой ID.
Если аргумент — число, возвращает его как есть.
Если строка, пытается найти пользователя по логину или имени.
"""
if isinstance(user_arg, int):
return user_arg
text = str(user_arg).strip()
if not text:
raise RedmineAPIError("User identifier cannot be empty.")
# Сначала пробуем интерпретировать как числовой ID
if text.isdigit():
return int(text)
# Затем ищем по логину
try:
users = redmine.user.filter(login=text)
# Фильтр Redmine по логину неточный (substring-поиск), поэтому
# выбираем только точные регистрозависимые совпадения логина (#60).
exact_matches = [u for u in users if getattr(u, "login", None) == text]
if len(exact_matches) == 1:
return int(exact_matches[0].id)
if len(exact_matches) > 1:
matches = ", ".join(str(u.id) for u in exact_matches[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
# Потом по имени
try:
users = redmine.user.filter(name=text)
if len(users) == 1:
return int(users[0].id)
if len(users) > 1:
matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5])
raise RedmineAPIError(
f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID."
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(
f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}",
original=exc,
) from exc
raise RedmineAPIError(
f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID."
)
def _get_current_user_id(redmine: Redmine) -> int:
"""Возвращает ID текущего пользователя."""
try:
current_user = redmine.user.get("current")
return int(current_user.id)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
def fetch_issues_with_spent_time(
from_date: str,
to_date: str,
user_id: int | str | None = None,
by_activity: bool = False,
) -> list[tuple[Issue, float, dict[str, float] | None]] | None:
"""
Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue.
If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element.
Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list(
redmine.time_entry.filter(
user_id=target_user_id,
from_date=from_date,
to_date=to_date,
)
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Агрегируем часы по issue.id (и активности, если требуется)
spent_time: dict[int, float] = {}
spent_by_activity: dict[int, dict[str, float]] = {}
issue_ids = set()
for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
iid = entry.issue.id
hours = float(entry.hours)
issue_ids.add(iid)
spent_time[iid] = spent_time.get(iid, 0.0) + hours
if by_activity:
activity_name = _get_activity_name(entry, activities_lookup)
by_act = spent_by_activity.setdefault(iid, {})
by_act[activity_name] = by_act.get(activity_name, 0.0) + hours
if not issue_ids:
return None
# Загружаем полные объекты задач чанками (#21)
try:
sorted_ids = sorted(issue_ids)
issues = _fetch_issues_chunked(redmine, sorted_ids)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
# по доступным данным.
returned_ids = {issue.id for issue in issues}
missing_ids = sorted(issue_ids - returned_ids)
if missing_ids:
lost_hours = sum(spent_time[iid] for iid in missing_ids)
print(
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
f"{lost_hours:g}h excluded from the report.",
file=sys.stderr,
)
# Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report,
# здесь оставляем порядок API как есть.
result = []
for issue in issues:
iid = issue.id
if iid not in spent_time:
continue
total_hours = spent_time[iid]
activity_breakdown = spent_by_activity.get(iid) if by_activity else None
result.append((issue, total_hours, activity_breakdown))
return result
def fetch_open_issues(
user_id: int | str | None = None,
) -> list[tuple[Issue, date | None]]:
"""
Fetch open issues assigned to the given user across all projects,
paired with the due date of each issue's fixed version.
If user_id is None, uses current user.
Returns list of (issue, due_date) tuples in API order; sorting is done
in the builder layer. Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
# Без limit: python-redmine автопагинацией вычитывает все страницы,
# как в time_entry.filter выше.
issues = list(
redmine.issue.filter(assigned_to_id=target_user_id, status_id="open")
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# fixed_version внутри задачи содержит только id/name, поэтому due_date
# приходится добирать отдельным запросом на каждую уникальную версию (#71).
# Отказ загрузки одной версии не валидует выгрузку: её задачи получат None.
due_by_version: dict[int, date | None] = {}
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
if version_ref is None:
continue
version_id = getattr(version_ref, "id", None)
if version_id is None or version_id in due_by_version:
continue
try:
version = redmine.version.get(version_id)
except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None
print(
f"⚠️ Could not load version {version_id}: {exc}. "
"Due date will be empty for its issues.",
file=sys.stderr,
)
due_by_version[version_id] = None
else:
due_by_version[version_id] = getattr(version, "due_date", None)
# Порядок задач сохраняем как в ответе API.
result: list[tuple[Issue, date | None]] = []
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
version_id = (
getattr(version_ref, "id", None) if version_ref is not None else None
)
due = due_by_version.get(version_id) if version_id is not None else None
result.append((issue, due))
return result