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

@@ -0,0 +1,124 @@
from datetime import date
from redminelib.resources import Issue
from .types import TaskRow, TaskSummary
from .utils import get_version
# В этом Redmine ровно пять приоритетов P1..P5; меньший номер -- срочнее.
PRIORITY_RANK: dict[str, int] = {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
# Ранг для неизвестного имени приоритета: такие задачи уходят в самый конец.
LOWEST_PRIORITY_RANK = len(PRIORITY_RANK) + 1
def priority_rank(name: str) -> int:
"""Возвращает ранг приоритета; неизвестное имя получает низший ранг."""
return PRIORITY_RANK.get(name, LOWEST_PRIORITY_RANK)
def _issue_version_name(issue: Issue) -> str:
"""Имя версии задачи или пустая строка, если версия не назначена."""
return get_version(issue, default="")
def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]:
"""Ключ сортировки: приоритет, затем срок по возрастанию, затем id.
Задачи без срока уходят в конец своей приоритетной группы: флаг
(due is None) сортирует False раньше True, а date.max служит нейтральным
значением, чтобы сравнение дат осталось корректным.
"""
issue, due = item
rank = priority_rank(str(issue.priority.name))
return (rank, due is None, due if due is not None else date.max, issue.id)
def build_task_rows(
items: list[tuple[Issue, date | None]],
status_translation: dict[str, str],
today: date | None = None,
) -> list[TaskRow]:
"""Строит отсортированный список строк режима --my-tasks.
items -- пары (задача, срок версии или None). today параметризован
для тестируемости; по умолчанию берётся текущая дата.
"""
if today is None:
today = date.today()
rows: list[TaskRow] = []
for issue, due in sorted(items, key=_sort_key):
status_name = str(issue.status)
rows.append(
{
"issue_id": issue.id,
"subject": str(issue.subject),
"status_ru": status_translation.get(status_name, status_name),
"priority": str(issue.priority.name),
"version_name": _issue_version_name(issue),
"due_text": due.isoformat() if due is not None else "",
"overdue": due is not None and due < today,
}
)
return rows
def calculate_task_summary(
items: list[tuple[Issue, date | None]],
today: date | None = None,
) -> TaskSummary:
"""Считает саммари по списку пар (задача, срок версии или None)."""
if today is None:
today = date.today()
overdue = 0
no_version = 0
no_due = 0
spent_hours = 0.0
estimated_hours = 0.0
estimated_count = 0
for issue, due in items:
if due is not None and due < today:
overdue += 1
version = getattr(issue, "fixed_version", None)
if version is None:
no_version += 1
elif due is None:
no_due += 1
raw_spent = getattr(issue, "spent_hours", None)
if raw_spent is not None:
spent_hours += float(raw_spent)
raw_estimated = getattr(issue, "estimated_hours", None)
if raw_estimated is not None:
estimated_hours += float(raw_estimated)
estimated_count += 1
return {
"total": len(items),
"overdue": overdue,
"no_version": no_version,
"no_due": no_due,
"spent_hours": spent_hours,
"estimated_hours": estimated_hours,
"estimated_count": estimated_count,
}
def format_summary_line(s: TaskSummary) -> str:
"""Форматирует однострочное саммари; блок оценки выводится при наличии данных."""
line = (
f"Открытых: {s['total']} | просроченных: {s['overdue']}"
f" | без версии: {s['no_version']} | без срока: {s['no_due']}"
f" | потрачено: {s['spent_hours']:g}h"
)
if s["estimated_count"] > 0:
line += (
f" | оценка: {s['estimated_hours']:g}h"
f" (по {s['estimated_count']} из {s['total']})"
)
return line