From f00c6705bab49f68dab256d7a857514bcf7e0d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 28 Oct 2026 15:53:50 +0700 Subject: [PATCH] feat: filter Wishlist from --my-tasks; document the mode (#71) 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. --- README.md | 4 +++ docs/USER_GUIDE.md | 68 +++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ redmine_reporter/cli.py | 11 +++++- redmine_reporter/task_list.py | 25 +++++++++++++ tests/test_cli.py | 40 +++++++++++++++++++++ tests/test_client.py | 1 + tests/test_config.py | 1 + tests/test_formatters.py | 1 + tests/test_mailer.py | 1 + tests/test_task_list.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_task_table.py | 1 + tests/test_yaml_config.py | 1 + 13 files changed, 224 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bfa40eb..d44a471 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ CLI-инструмент для генерации отчётов по зада - Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц. - `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска. - `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке. +- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.md#список-открытых-задач). - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md). ## Установка @@ -57,6 +58,9 @@ redmine-reporter # Сохранить в файл и сдвинуть начало периода для следующего запуска redmine-reporter --commit + +# Список ваших открытых задач (сортировка по срочности) +redmine-reporter --my-tasks ``` ## Документация diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 55bad56..fccb924 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -6,6 +6,7 @@ - [Первоначальная настройка](#первоначальная-настройка) - [Выбор периода](#выбор-периода) - [Отчёт за другого пользователя](#отчёт-за-другого-пользователя) +- [Список открытых задач](#список-открытых-задач) - [Экспорт в файл](#экспорт-в-файл) - [Содержимое отчёта](#содержимое-отчёта) - [Отправка по email](#отправка-по-email) @@ -115,6 +116,70 @@ redmine-reporter --user-name "Иванов Иван Иванович" - Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID). - Ноль совпадений → ошибка «User '...' not found...». +## Список открытых задач + +`--my-tasks` -- отдельный режим: вместо отчёта по затраченному времени выводит +открытые задачи, назначенные на вас, по всем проектам. Отчётное окно периода +к режиму не применяется. + +```bash +redmine-reporter --my-tasks # задачи владельца API-ключа +redmine-reporter --my-tasks --user-id 42 # чужой список по ID +redmine-reporter --my-tasks --user-login ivanov # по логину +``` + +Правила: + +- `--user-id` / `--user-login` / `--user-name` выбирают, чей список показать; по умолчанию -- владелец API-ключа. Разрешение пользователей то же, что для отчёта (см. [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)). +- `--url`, `--api-key`, `--config`, `--config-path`, `--verbose`, `--debug` работают как обычно. +- Совмещение с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) -- ошибка «--my-tasks cannot be used with report-generating flags.», exit 1. +- Задачи в версии `Wishlist` исключаются из режима целиком: не попадают ни в таблицу, ни в сводку. + +Сортировка -- от срочного к менее срочному: + +1. Приоритет `P1..P5`: меньший номер срочнее; неизвестное имя приоритета получает низший ранг. +2. Внутри приоритетной группы -- срок версии (due date) по возрастанию. +3. Задачи без версии или без срока -- в конец своей приоритетной группы. +4. При прочих равных -- по возрастанию ID. + +Таблица печатается в stdout, сводка -- в stderr перед таблицей. При +перенаправлении (`redmine-reporter --my-tasks > tasks.txt`) в файл попадает +только таблица. + +Колонки таблицы: ID | Тема | Статус | Приор. | Версия | Срок. Пример +(синтетические данные, ID в терминале кликабелен): + +``` + ID Тема Статус Приор. Версия Срок +---- -------------------------------- -------------- -------- -------- -------------- + 101 Критический дефект сборки образа В работе P1 v2.4.0 2026-10-15 (!) + 102 Обновить документацию API Новая P2 v2.5.0 2026-12-01 + 103 Ревью pull request Обратная связь P3 - - +``` + +- ID -- гиперссылка (OSC 8) на страницу задачи `/issues/`: в терминалах с поддержкой кликабельна, без поддержки остаётся обычным номером. +- Срок -- дата релиза целевой версии; просроченный (раньше сегодняшней даты) помечается `(!)`. +- Пустые «Версия» и «Срок» -- прочерк `-`. +- Статусы переводятся на русский тем же словарём, что и в отчётах: встроенный словарь + `report.status_translation` (см. [Перевод статусов](#перевод-статусов)). + +Сводка (пример с синтетическими данными): + +``` +Открытых: 3 | просроченных: 1 | без версии: 1 | без срока: 0 | потрачено: 12.5h | оценка: 8h (по 2 из 3) +``` + +- «просроченных» -- задачи со сроком версии раньше сегодняшней даты. +- «без версии» -- задачи без целевой версии; «без срока» -- задачи с версией, но без известной даты релиза. +- «потрачено» -- сумма полей `spent_hours` задач списка. +- «оценка» -- сумма полей `estimated_hours`; «(по N из M)» -- у скольких задач из списка есть оценка. Если оценок нет ни у одной задачи, блок «оценка» не выводится. + +Сроки версий дозагружаются отдельным запросом на каждую уникальную версию +списка. Недоступная версия -- предупреждение в stderr и пустой срок у её +задач, вывод не прерывается. + +Пустой список (в том числе когда все открытые задачи попали в исключаемую +версию `Wishlist`): «ℹ️ Открытых задач нет.» (stderr), exit 0. + ## Экспорт в файл Флаг `--output` определяется по четырём правилам: @@ -284,6 +349,7 @@ redmine-reporter --send --commit | `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. | | `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. | | `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. | +| `--my-tasks` | Вместо отчёта вывести открытые задачи, назначенные на вас (или на пользователя из `--user-id`/`--user-login`/`--user-name`): таблица с сортировкой по срочности + сводка в stderr. Несовместим с `--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`. Подробнее: [Список открытых задач](#список-открытых-задач). | ## Устранение неполадок @@ -294,6 +360,7 @@ redmine-reporter --send --commit | «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. | | «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. | | Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. | +| «--my-tasks cannot be used with report-generating flags.» (exit 1) | `--my-tasks` совмещён с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) | Запускайте режим отдельно: список задач и отчёт -- разные команды (см. [Список открытых задач](#список-открытых-задач)). | | «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. | | Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. | | «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). | @@ -303,6 +370,7 @@ redmine-reporter --send --commit | «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. | | Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. | | Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. | +| «⚠️ Could not load version ...: Due date will be empty for its issues.» в режиме `--my-tasks` | Версия из списка задач недоступна (нет прав, удалена, сбой сети) | Некритично: срок этой версии останется пустым; проверьте права доступа к проекту версии. | | Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. | API-ключ в текстах ошибок маскируется (`***`). diff --git a/pyproject.toml b/pyproject.toml index 78f6d5b..9f57bab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,3 +62,6 @@ ignore_missing_imports = true [tool.isort] profile = "black" + +[tool.ruff.lint.isort] +known-first-party = ["redmine_reporter"] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 81001e7..ca2d82e 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -16,7 +16,12 @@ from .formatters.factory import get_console_formatter, get_formatter_by_extensio from .formatters.task_table import TaskTableFormatter from .mailer import send_report from .report_builder import build_grouped_report, calculate_summary -from .task_list import build_task_rows, calculate_task_summary, format_summary_line +from .task_list import ( + build_task_rows, + calculate_task_summary, + filter_excluded_versions, + format_summary_line, +) from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config # Маскируем API-ключ Redmine в тексте ошибок (#55): @@ -287,6 +292,10 @@ def _run_my_tasks(args: argparse.Namespace) -> int: logging.exception("Unexpected error") # noqa: LOG015 return 1 + # Бакет-версии (Wishlist) исключаются до проверки на пустоту: если все + # открытые задачи в них, пользователь видит "Открытых задач нет". + items = filter_excluded_versions(items) + if not items: print("ℹ️ Открытых задач нет.", file=sys.stderr) return 0 diff --git a/redmine_reporter/task_list.py b/redmine_reporter/task_list.py index f5055e9..5cebebd 100644 --- a/redmine_reporter/task_list.py +++ b/redmine_reporter/task_list.py @@ -22,6 +22,31 @@ 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. diff --git a/tests/test_cli.py b/tests/test_cli.py index ff67bc7..ab6c233 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from datetime import date from unittest import mock import pytest + from redmine_reporter.cli import main, parse_date_range from redmine_reporter.client import RedmineAPIError @@ -2115,6 +2116,45 @@ class TestMyTasksFlag: assert "потрачено:" in captured.err assert "Открытых:" not in captured.out + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_wishlist_filtered_out_of_table_and_summary( + self, mock_fetch, capsys + ): + """Задача из Wishlist не попадает ни в таблицу, ни в саммари.""" + wishlist = _make_task_issue(50, subject="Идея на потом") + wishlist.fixed_version = mock.MagicMock() + wishlist.fixed_version.name = "Wishlist" + normal = _make_task_issue(51, subject="Рабочая задача") + mock_fetch.return_value = [(wishlist, None), (normal, None)] + + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + + assert _expected_link(51) in captured.out + assert "Рабочая задача" in captured.out + assert _expected_link(50) not in captured.out + assert "Идея на потом" not in captured.out + assert "Открытых: 1" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_only_wishlist_reports_empty_and_exits_zero( + self, mock_fetch, capsys + ): + """Все задачи в Wishlist -- выборка пуста: 'Открытых задач нет', код 0.""" + wishlist = _make_task_issue(60, subject="Ещё идея") + wishlist.fixed_version = mock.MagicMock() + wishlist.fixed_version.name = "Wishlist" + mock_fetch.return_value = [(wishlist, None)] + + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + assert "Открытых задач нет" in captured.err + assert captured.out == "" + @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_open_issues") def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys): diff --git a/tests/test_client.py b/tests/test_client.py index e8f005b..65256e9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ from datetime import date from unittest import mock import pytest + from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues from redmine_reporter.config import DEFAULT_REDMINE_VERIFY diff --git a/tests/test_config.py b/tests/test_config.py index a6d0afe..824ea98 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ from typing import ClassVar from unittest import mock import pytest + from redmine_reporter.config import AppConfig, Config, EmailConfig diff --git a/tests/test_formatters.py b/tests/test_formatters.py index b5d42a0..091276a 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -4,6 +4,7 @@ from unittest import mock import pytest from odf.opendocument import OpenDocument, OpenDocumentText + from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.factory import get_formatter_by_extension diff --git a/tests/test_mailer.py b/tests/test_mailer.py index ec19ebc..8a6a897 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -2,6 +2,7 @@ import smtplib from unittest import mock import pytest + from redmine_reporter.client import RedmineAPIError from redmine_reporter.config import EmailConfig, SmtpConfig from redmine_reporter.mailer import build_message, send_report diff --git a/tests/test_task_list.py b/tests/test_task_list.py index e06aad5..257c574 100644 --- a/tests/test_task_list.py +++ b/tests/test_task_list.py @@ -4,9 +4,11 @@ from datetime import date from unittest import mock from redmine_reporter.task_list import ( + EXCLUDED_VERSION_NAMES, PRIORITY_RANK, build_task_rows, calculate_task_summary, + filter_excluded_versions, format_summary_line, priority_rank, ) @@ -100,6 +102,72 @@ def test_rows_without_due_go_last_and_tiebreak_by_id(): assert [r["issue_id"] for r in rows] == [30, 12, 25] +# -- Фильтр версий-исключений (Wishlist) -- + + +def test_excluded_version_names_constant(): + """Точный состав бакет-версий, исключаемых из --my-tasks.""" + assert EXCLUDED_VERSION_NAMES == frozenset({"Wishlist"}) + + +def test_filter_excludes_wishlist_issue(): + """Задача в версии Wishlist удаляется из выборки.""" + items = [ + (make_issue(1, fixed_version="Wishlist"), None), + (make_issue(2, fixed_version="v2.5.0"), date(2026, 11, 1)), + ] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [2] + + +def test_filter_keeps_issue_without_version(): + """Задача без версии фильтром не трогается.""" + items = [(make_issue(3), None)] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [3] + + +def test_filter_keeps_regular_version_without_due(): + """Обычная версия без срока -- не бакет, задача остаётся.""" + items = [(make_issue(4, fixed_version="v3.0.0"), None)] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [4] + + +def test_filter_is_case_sensitive(): + """Сравнение имени версии точное: wishlist строчными -- НЕ исключение.""" + items = [ + (make_issue(5, fixed_version="wishlist"), None), + (make_issue(6, fixed_version="WISHLIST"), None), + (make_issue(7, fixed_version="Wishlist"), None), + ] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [5, 6] + + +def test_filter_returns_only_excluded_when_all_match(): + items = [(make_issue(8, fixed_version="Wishlist"), None)] + assert filter_excluded_versions(items) == [] + + +def test_filter_preserves_due_pairing(): + """Фильтр не теряет парную к задаче дату срока.""" + due = date(2026, 12, 1) + items = [(make_issue(9, fixed_version="v1"), due)] + ((issue, kept_due),) = filter_excluded_versions(items) + assert issue.id == 9 + assert kept_due is due + + +def test_filter_tolerates_version_without_name_attribute(): + """fixed_version без атрибута name -- задача остаётся (getattr-безопасность).""" + issue = make_issue(10) + issue.fixed_version = mock.MagicMock(spec=[]) + items = [(issue, None)] + kept = filter_excluded_versions(items) + assert [i.id for i, _ in kept] == [10] + + # -- Просроченность -- diff --git a/tests/test_task_table.py b/tests/test_task_table.py index ba8e917..fa34327 100644 --- a/tests/test_task_table.py +++ b/tests/test_task_table.py @@ -4,6 +4,7 @@ import re from typing import cast import pytest + from redmine_reporter.formatters.task_table import TaskTableFormatter from redmine_reporter.types import TaskRow diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index b19930f..e26299b 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest import mock import yaml + from redmine_reporter.yaml_config import ( check_file_permissions, ensure_config_dir,