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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user