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.
This commit is contained in:
Кокос Артем Николаевич
2026-10-28 15:26:41 +07:00
parent 3ce4f408ed
commit 5d1cf4ab74
27 changed files with 1255 additions and 137 deletions

View File

@@ -1,5 +1,6 @@
import sys
from typing import Any, Dict, List, Optional, Tuple, Union
from datetime import date
from typing import Any
import requests
import truststore
@@ -20,13 +21,13 @@ ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None):
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]:
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:
@@ -122,7 +123,7 @@ def _format_redmine_error(exc: Exception) -> str:
return str(exc)
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
def _load_time_entry_activities(redmine: Redmine) -> dict[int, str]:
"""Загружает справочник типов активности time entries.
Возвращает словарь id -> name. Если справочник недоступен,
@@ -131,7 +132,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try:
activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities}
except Exception as exc:
except Exception as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries.
print(
@@ -142,7 +143,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str:
def _get_activity_name(entry, activities: dict[int, str]) -> str:
"""Определяет название активности для time entry."""
activity = getattr(entry, "activity", None)
if activity is None:
@@ -162,9 +163,9 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity)
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
def _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
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)
@@ -175,7 +176,7 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
return all_issues
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int:
def _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int:
"""Преобразует строковый идентификатор пользователя в числовой ID.
Если аргумент — число, возвращает его как есть.
@@ -248,9 +249,9 @@ def _get_current_user_id(redmine: Redmine) -> int:
def fetch_issues_with_spent_time(
from_date: str,
to_date: str,
user_id: Optional[Union[int, str]] = None,
user_id: int | str | None = None,
by_activity: bool = False,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
) -> 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.
@@ -281,8 +282,8 @@ def fetch_issues_with_spent_time(
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]] = {}
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"):
@@ -333,3 +334,66 @@ def fetch_issues_with_spent_time(
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