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,9 @@
import os
from datetime import date
from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = {
@@ -57,7 +57,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None
assert len(result) == 1
issue, total_hours, _activities = result[0]
_issue, total_hours, _activities = result[0]
assert total_hours == 3.5
@@ -805,3 +805,200 @@ def test_fetch_works_without_dedup_argument(mock_redmine_class):
assert result is not None
assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()