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.
This commit is contained in:
Кокос Артем Николаевич
2026-10-28 15:53:50 +07:00
parent 5d1cf4ab74
commit f00c6705ba
13 changed files with 224 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ CLI-инструмент для генерации отчётов по зада
- Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц. - Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц.
- `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска. - `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска.
- `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке. - `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке.
- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.md#список-открытых-задач).
- YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md). - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md).
## Установка ## Установка
@@ -57,6 +58,9 @@ redmine-reporter
# Сохранить в файл и сдвинуть начало периода для следующего запуска # Сохранить в файл и сдвинуть начало периода для следующего запуска
redmine-reporter --commit redmine-reporter --commit
# Список ваших открытых задач (сортировка по срочности)
redmine-reporter --my-tasks
``` ```
## Документация ## Документация

View File

@@ -6,6 +6,7 @@
- [Первоначальная настройка](#первоначальная-настройка) - [Первоначальная настройка](#первоначальная-настройка)
- [Выбор периода](#выбор-периода) - [Выбор периода](#выбор-периода)
- [Отчёт за другого пользователя](#отчёт-за-другого-пользователя) - [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)
- [Список открытых задач](#список-открытых-задач)
- [Экспорт в файл](#экспорт-в-файл) - [Экспорт в файл](#экспорт-в-файл)
- [Содержимое отчёта](#содержимое-отчёта) - [Содержимое отчёта](#содержимое-отчёта)
- [Отправка по email](#отправка-по-email) - [Отправка по email](#отправка-по-email)
@@ -115,6 +116,70 @@ redmine-reporter --user-name "Иванов Иван Иванович"
- Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID). - Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID).
- Ноль совпадений → ошибка «User '...' not found...». - Ноль совпадений → ошибка «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) на страницу задачи `<url>/issues/<id>`: в терминалах с поддержкой кликабельна, без поддержки остаётся обычным номером.
- Срок -- дата релиза целевой версии; просроченный (раньше сегодняшней даты) помечается `(!)`.
- Пустые «Версия» и «Срок» -- прочерк `-`.
- Статусы переводятся на русский тем же словарём, что и в отчётах: встроенный словарь + `report.status_translation` (см. [Перевод статусов](#перевод-статусов)).
Сводка (пример с синтетическими данными):
```
Открытых: 3 | просроченных: 1 | без версии: 1 | без срока: 0 | потрачено: 12.5h | оценка: 8h (по 2 из 3)
```
- «просроченных» -- задачи со сроком версии раньше сегодняшней даты.
- «без версии» -- задачи без целевой версии; «без срока» -- задачи с версией, но без известной даты релиза.
- «потрачено» -- сумма полей `spent_hours` задач списка.
- «оценка» -- сумма полей `estimated_hours`; «(по N из M)» -- у скольких задач из списка есть оценка. Если оценок нет ни у одной задачи, блок «оценка» не выводится.
Сроки версий дозагружаются отдельным запросом на каждую уникальную версию
списка. Недоступная версия -- предупреждение в stderr и пустой срок у её
задач, вывод не прерывается.
Пустой список (в том числе когда все открытые задачи попали в исключаемую
версию `Wishlist`): «ℹ️ Открытых задач нет.» (stderr), exit 0.
## Экспорт в файл ## Экспорт в файл
Флаг `--output` определяется по четырём правилам: Флаг `--output` определяется по четырём правилам:
@@ -284,6 +349,7 @@ redmine-reporter --send --commit
| `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. | | `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. |
| `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. | | `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. |
| `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. | | `--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, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. | | «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. |
| «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. | | «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. |
| Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. | | Указано несколько `--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` с неизвестным расширением | Используйте одно из поддерживаемых расширений. | | «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. |
| Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. | | Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. |
| «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). | | «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). |
@@ -303,6 +370,7 @@ redmine-reporter --send --commit
| «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. | | «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. |
| Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. | | Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. |
| Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. | | Предупреждение о 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`. | | Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. |
API-ключ в текстах ошибок маскируется (`***`). API-ключ в текстах ошибок маскируется (`***`).

View File

@@ -62,3 +62,6 @@ ignore_missing_imports = true
[tool.isort] [tool.isort]
profile = "black" profile = "black"
[tool.ruff.lint.isort]
known-first-party = ["redmine_reporter"]

View File

@@ -16,7 +16,12 @@ from .formatters.factory import get_console_formatter, get_formatter_by_extensio
from .formatters.task_table import TaskTableFormatter from .formatters.task_table import TaskTableFormatter
from .mailer import send_report from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary 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 from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55): # Маскируем API-ключ Redmine в тексте ошибок (#55):
@@ -287,6 +292,10 @@ def _run_my_tasks(args: argparse.Namespace) -> int:
logging.exception("Unexpected error") # noqa: LOG015 logging.exception("Unexpected error") # noqa: LOG015
return 1 return 1
# Бакет-версии (Wishlist) исключаются до проверки на пустоту: если все
# открытые задачи в них, пользователь видит "Открытых задач нет".
items = filter_excluded_versions(items)
if not items: if not items:
print("ℹ️ Открытых задач нет.", file=sys.stderr) print("ℹ️ Открытых задач нет.", file=sys.stderr)
return 0 return 0

View File

@@ -22,6 +22,31 @@ def _issue_version_name(issue: Issue) -> str:
return get_version(issue, default="") 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]: def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]:
"""Ключ сортировки: приоритет, затем срок по возрастанию, затем id. """Ключ сортировки: приоритет, затем срок по возрастанию, затем id.

View File

@@ -3,6 +3,7 @@ from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.cli import main, parse_date_range from redmine_reporter.cli import main, parse_date_range
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
@@ -2115,6 +2116,45 @@ class TestMyTasksFlag:
assert "потрачено:" in captured.err assert "потрачено:" in captured.err
assert "Открытых:" not in captured.out 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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues") @mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys): def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys):

View File

@@ -3,6 +3,7 @@ from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY from redmine_reporter.config import DEFAULT_REDMINE_VERIFY

View File

@@ -6,6 +6,7 @@ from typing import ClassVar
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.config import AppConfig, Config, EmailConfig from redmine_reporter.config import AppConfig, Config, EmailConfig

View File

@@ -4,6 +4,7 @@ from unittest import mock
import pytest import pytest
from odf.opendocument import OpenDocument, OpenDocumentText from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension from redmine_reporter.formatters.factory import get_formatter_by_extension

View File

@@ -2,6 +2,7 @@ import smtplib
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
from redmine_reporter.config import EmailConfig, SmtpConfig from redmine_reporter.config import EmailConfig, SmtpConfig
from redmine_reporter.mailer import build_message, send_report from redmine_reporter.mailer import build_message, send_report

View File

@@ -4,9 +4,11 @@ from datetime import date
from unittest import mock from unittest import mock
from redmine_reporter.task_list import ( from redmine_reporter.task_list import (
EXCLUDED_VERSION_NAMES,
PRIORITY_RANK, PRIORITY_RANK,
build_task_rows, build_task_rows,
calculate_task_summary, calculate_task_summary,
filter_excluded_versions,
format_summary_line, format_summary_line,
priority_rank, 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] 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]
# -- Просроченность -- # -- Просроченность --

View File

@@ -4,6 +4,7 @@ import re
from typing import cast from typing import cast
import pytest import pytest
from redmine_reporter.formatters.task_table import TaskTableFormatter from redmine_reporter.formatters.task_table import TaskTableFormatter
from redmine_reporter.types import TaskRow from redmine_reporter.types import TaskRow

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
import yaml import yaml
from redmine_reporter.yaml_config import ( from redmine_reporter.yaml_config import (
check_file_permissions, check_file_permissions,
ensure_config_dir, ensure_config_dir,