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:
124
redmine_reporter/task_list.py
Normal file
124
redmine_reporter/task_list.py
Normal 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
|
||||
Reference in New Issue
Block a user