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,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()
|
||||
|
||||
Reference in New Issue
Block a user