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:
Кокос Артем Николаевич
2026-10-28 15:26:41 +07:00
parent 3ce4f408ed
commit 5d1cf4ab74
27 changed files with 1255 additions and 137 deletions

View File

@@ -1,9 +1,10 @@
import os
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
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
@@ -1997,3 +1998,153 @@ class TestSendConfirmation:
assert "bcc@example.com" in captured.out
assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ")
# ---------------------------------------------------------------------------
# #71: --my-tasks tests
# ---------------------------------------------------------------------------
def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"):
"""Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима.
build_task_rows читает nested-атрибуты (priority.name); unset-атрибут
MagicMock был бы автосконструирован и str() дал бы нестабильный текст.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
issue.fixed_version = None
issue.spent_hours = 0.0
issue.estimated_hours = None
return issue
def _expected_link(issue_id):
"""Точная OSC 8-последовательность для ссылки на задачу в stdout."""
return (
f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
)
class TestMyTasksFlag:
"""Tests for --my-tasks flag (#71)."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@pytest.mark.parametrize(
"flag_args",
[
["--date", "2026-01-01--2026-01-31"],
["--output", "/tmp/report.xlsx"],
["--compact"],
["--summary"],
["--by-activity"],
["--no-time"],
["--commit"],
["--send"],
],
)
def test_my_tasks_rejects_report_flags(self, flag_args, capsys):
"""--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение."""
code = main(["--my-tasks"] + flag_args)
captured = capsys.readouterr()
assert code == 1
assert "--my-tasks cannot be used with report-generating flags" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys):
"""Пустой список открытых задач -- выход 0 и сообщение в stderr."""
mock_fetch.return_value = []
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_passes_user_id(self, mock_fetch):
"""--user-id передаётся в fetch_open_issues."""
mock_fetch.return_value = []
code = main(["--my-tasks", "--user-id", "42"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_no_user_flag_passes_none(self, mock_fetch):
"""Без флагов пользователя в fetch_open_issues уходит user_id=None."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] is None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_happy_path(self, mock_fetch, capsys):
"""Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr."""
issue_overdue = _make_task_issue(42, subject="Починить баг")
issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending")
# Срок глубоко в прошлом: overdue не зависит от даты прогона тестов.
mock_fetch.return_value = [
(issue_fresh, None),
(issue_overdue, date(2000, 1, 1)),
]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
# Ссылка на base_url из env теста с точной escape-последовательностью.
assert _expected_link(42) in captured.out
assert _expected_link(43) in captured.out
# Переведённые статусы попадают в таблицу.
assert "В работе" in captured.out
assert "Ожидание" in captured.out
assert "Починить баг" in captured.out
# Саммари -- в stderr, таблица -- не там.
assert "Открытых: 2" in captured.err
assert "просроченных: 1" in captured.err
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_api_error_exits_one(self, mock_fetch, capsys):
"""RedmineAPIError -- понятное сообщение и выход 1."""
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys):
"""Непредвиденное исключение -- Unexpected error и выход 1."""
mock_fetch.side_effect = RuntimeError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Unexpected error" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.TaskTableFormatter")
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_format_error_exits_one(
self, mock_fetch, mock_formatter_cls, capsys
):
"""Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека."""
mock_fetch.return_value = [(_make_task_issue(7), None)]
mock_formatter_cls.return_value.format.side_effect = ValueError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "❌ Formatting error: boom" in captured.err