Tasks in the "Wishlist" bucket version are now excluded from the list and summary entirely (no due date, noise backlog); exact case match, tasks without a version are unaffected. Document --my-tasks in README and the user guide (sorting, summary metrics, OSC 8 links, stream separation); all examples use synthetic data. Declare ruff known-first-party in pyproject so the lint result is CWD-independent.
150 lines
5.9 KiB
Python
150 lines
5.9 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="")
|
||
|
||
|
||
# "Wishlist" -- постоянная бакет-версия без срока (личный бакет Wendy, факт
|
||
# из трекера): задачи в ней -- отложенные идеи, создающие мусорный фон в
|
||
# персональной выборке, поэтому режим --my-tasks исключает их целиком --
|
||
# из таблицы и из саммари. Сравнение имени точное, без нормализации регистра.
|
||
EXCLUDED_VERSION_NAMES: frozenset[str] = frozenset({"Wishlist"})
|
||
|
||
|
||
def filter_excluded_versions(
|
||
items: list[tuple[Issue, date | None]],
|
||
) -> list[tuple[Issue, date | None]]:
|
||
"""Убирает задачи версий-исключений из выборки --my-tasks.
|
||
|
||
Доступ к fixed_version.name getattr-безопасный: задача без версии или
|
||
с версией без имени остаётся в выборке. Даты в парах сохраняются как есть.
|
||
"""
|
||
kept: list[tuple[Issue, date | None]] = []
|
||
for item in items:
|
||
version = getattr(item[0], "fixed_version", None)
|
||
name = getattr(version, "name", None) if version is not None else None
|
||
if name and str(name) in EXCLUDED_VERSION_NAMES:
|
||
continue
|
||
kept.append(item)
|
||
return kept
|
||
|
||
|
||
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
|