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.
125 lines
4.6 KiB
Python
125 lines
4.6 KiB
Python
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
|