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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user