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:
@@ -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
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -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) на страницу задачи `<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` определяется по четырём правилам:
|
||||
@@ -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-ключ в текстах ошибок маскируется (`***`).
|
||||
|
||||
@@ -62,3 +62,6 @@ ignore_missing_imports = true
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["redmine_reporter"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from redmine_reporter.config import AppConfig, Config, EmailConfig
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
# -- Просроченность --
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user