fix: warn on silent data loss in report
Issues not returned by issue.filter (no access / deleted) and failures of the time entry activities lookup were silently swallowed: the report was built from partial data without telling the user. Now both cases emit a warning to stderr (project ⚠️ style, #28): - skipped issue IDs and total lost hours after issue matching; - activities lookup failure (still returns {} and falls back to activity names from time entries). These are warnings, not errors: the report is still built from available data and the exit code is unchanged. Closes #61
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
@@ -121,7 +122,14 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
|
|||||||
try:
|
try:
|
||||||
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
activities = redmine.enumeration.filter(resource="time_entry_activities")
|
||||||
return {int(a.id): str(a.name) for a in activities}
|
return {int(a.id): str(a.name) for a in activities}
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
# #61: не глотаем сбой молча — предупреждаем, что разбивка по
|
||||||
|
# активностям будет построена по сырым данным из самих entries.
|
||||||
|
print(
|
||||||
|
f"⚠️ Could not load time entry activities: {exc}. "
|
||||||
|
"Activity names will be taken from time entries.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -346,6 +354,20 @@ def fetch_issues_with_spent_time(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
|
||||||
|
|
||||||
|
# #61: задачи могли не вернуться из issue.filter (нет прав / удалены) —
|
||||||
|
# предупреждаем о выпавших задачах и потерянных часах, но строим отчёт
|
||||||
|
# по доступным данным.
|
||||||
|
returned_ids = {issue.id for issue in issues}
|
||||||
|
missing_ids = sorted(issue_ids - returned_ids)
|
||||||
|
if missing_ids:
|
||||||
|
lost_hours = sum(spent_time[iid] for iid in missing_ids)
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(missing_ids)} issue(s) unavailable (no access or deleted; "
|
||||||
|
f"IDs: {', '.join(str(i) for i in missing_ids)}): "
|
||||||
|
f"{lost_hours:g}h excluded from the report.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
# Сопоставляем задачи с суммарным временем.
|
# Сопоставляем задачи с суммарным временем.
|
||||||
# Сортировка выполняется в report_builder.build_grouped_report,
|
# Сортировка выполняется в report_builder.build_grouped_report,
|
||||||
# здесь оставляем порядок API как есть.
|
# здесь оставляем порядок API как есть.
|
||||||
|
|||||||
@@ -969,3 +969,111 @@ def test_dedup_accepts_naive_cutoff(mock_redmine_class):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert [r[0].id for r in result] == [2]
|
assert [r[0].id for r in result] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
# -- #61: предупреждения о молчаливой потере данных --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_warns_about_missing_issues(mock_redmine_class, capsys):
|
||||||
|
"""Задачи, не вернувшиеся из issue.filter, попадают в warning в stderr (#61).
|
||||||
|
|
||||||
|
Отчёт при этом строится по доступным данным — это предупреждение, не ошибка.
|
||||||
|
"""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
def make_entry(issue_id, hours):
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = issue_id
|
||||||
|
e.hours = hours
|
||||||
|
return e
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = [
|
||||||
|
make_entry(1, 1.0),
|
||||||
|
make_entry(2, 2.5),
|
||||||
|
make_entry(3, 0.75),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_issue1.project = "P"
|
||||||
|
mock_issue1.subject = "T"
|
||||||
|
mock_issue1.status = "New"
|
||||||
|
# Задачи 2 и 3 не вернулись (нет прав / удалены)
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [1]
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "2" in captured.err
|
||||||
|
assert "3" in captured.err
|
||||||
|
assert "3.25" in captured.err # сумма потерянных часов 2.5 + 0.75
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_no_warning_when_all_issues_returned(mock_redmine_class, capsys):
|
||||||
|
"""Если все задачи вернулись, stderr остаётся чистым (#61)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = 1
|
||||||
|
e.hours = 1.0
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert captured.err == ""
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_warns_when_activities_lookup_fails(mock_redmine_class, capsys):
|
||||||
|
"""Сбой справочника активностей — warning в stderr, отчёт строится (#61)."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
mock_redmine.enumeration.filter.side_effect = Exception("enumeration boom")
|
||||||
|
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = 1
|
||||||
|
e.hours = 1.0
|
||||||
|
e.activity.id = 9
|
||||||
|
e.activity.name = "Dev"
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e]
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31", by_activity=True)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[0][2] == {"Dev": 1.0} # fallback на имя из самого entry
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "activit" in captured.err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_time_entry_activities_returns_empty_on_failure(capsys):
|
||||||
|
"""_load_time_entry_activities возвращает {} и пишет warning при сбое (#61)."""
|
||||||
|
from redmine_reporter.client import _load_time_entry_activities
|
||||||
|
|
||||||
|
redmine = mock.MagicMock()
|
||||||
|
redmine.enumeration.filter.side_effect = Exception("enumeration boom")
|
||||||
|
|
||||||
|
assert _load_time_entry_activities(redmine) == {}
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "activit" in captured.err.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user