From 594db902278c0aab0915d7b6dd2402918aa64a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 12:27:50 +0700 Subject: [PATCH] fix: warn on silent data loss in report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- redmine_reporter/client.py | 24 ++++++++- tests/test_client.py | 108 +++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index dbe6b69..14a432f 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,3 +1,4 @@ +import sys from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union @@ -121,7 +122,14 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: try: activities = redmine.enumeration.filter(resource="time_entry_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 {} @@ -346,6 +354,20 @@ def fetch_issues_with_spent_time( except Exception as 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, # здесь оставляем порядок API как есть. diff --git a/tests/test_client.py b/tests/test_client.py index 204eb7f..35accff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -969,3 +969,111 @@ def test_dedup_accepts_naive_cutoff(mock_redmine_class): assert result is not None 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()