diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 0ed73c5..0564326 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -4,7 +4,6 @@ from redminelib import Redmine from redminelib.resources import Issue from .config import Config -from .utils import get_version def _get_redmine_auth_kwargs() -> Dict[str, Any]: @@ -54,13 +53,12 @@ def fetch_issues_with_spent_time( issue_list_str = ",".join(str(i) for i in issue_ids) issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc") - # Сопоставляем задачи с суммарным временем + # Сопоставляем задачи с суммарным временем. + # Сортировка выполняется в report_builder.build_grouped_report, + # здесь оставляем порядок API как есть. result = [] for issue in issues: total_hours = spent_time.get(issue.id, 0.0) result.append((issue, total_hours)) - # Сортируем по (проект, версия) - result.sort(key=lambda x: (str(x[0].project), get_version(x[0]))) - return result diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index e3189e1..5c0c75e 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -35,7 +35,7 @@ def build_grouped_report( """ # Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе - issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]))) + issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)) rows: List[ReportRow] = [] prev_project: str = "" diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index 5011fd4..3501afd 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -33,7 +33,8 @@ def get_version(issue) -> str: version = getattr(issue, "fixed_version", None) if version is None: return "" - return str(version) + name = getattr(version, "name", None) + return str(name) if name else str(version) def hours_to_human(hours: float) -> str: diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index b3965ee..fa71904 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -105,3 +105,15 @@ def test_build_grouped_report_preserves_issue_id_and_subject(): rows = build_grouped_report(issues) assert rows[0]["issue_id"] == 42 assert rows[0]["subject"] == "Моя задача" + + +def test_build_grouped_report_deterministic_order_within_group(): + """Задачи в одной группе проект+версия упорядочены по issue.id + независимо от порядка на входе (#33).""" + issues = [ + (MockIssue("P", "Task C", "New", "v1.0", 103), 1.0), + (MockIssue("P", "Task A", "New", "v1.0", 101), 1.0), + (MockIssue("P", "Task B", "New", "v1.0", 102), 1.0), + ] + rows = build_grouped_report(issues) + assert [r["issue_id"] for r in rows] == [101, 102, 103] diff --git a/tests/test_utils.py b/tests/test_utils.py index 36bff34..007f149 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -78,3 +78,35 @@ def test_get_version_none_attribute(): fixed_version = None assert get_version(MockIssue()) == "" + + +def test_get_version_with_redminelib_version_object(): + """redminelib Version: str() возвращает ID, .name — человекочитаемое имя.""" + + class MockVersion: + """Имитирует redminelib.resources.Version — str() даёт ID.""" + + def __init__(self, vid, name): + self.id = vid + self.name = name + + def __str__(self): + return str(self.id) + + class MockIssue: + fixed_version = MockVersion(42, "v2.5.0") + + assert get_version(MockIssue()) == "v2.5.0" + + +def test_get_version_falls_back_to_str_when_no_name(): + """Если у объекта версии нет .name — fallback на str().""" + + class MockVersionNoName: + def __str__(self): + return "fallback-id" + + class MockIssue: + fixed_version = MockVersionNoName() + + assert get_version(MockIssue()) == "fallback-id"