From 5d1cf4ab7493e3073930dab5290f2e39b9f8f318 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: Wed, 28 Oct 2026 15:26:41 +0700 Subject: [PATCH 1/3] 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. --- redmine_reporter/cli.py | 90 ++++++- redmine_reporter/client.py | 90 ++++++- redmine_reporter/config.py | 22 +- redmine_reporter/formatters/base.py | 8 +- redmine_reporter/formatters/console.py | 10 +- redmine_reporter/formatters/csv.py | 5 +- redmine_reporter/formatters/factory.py | 10 +- redmine_reporter/formatters/html.py | 5 +- redmine_reporter/formatters/json.py | 5 +- redmine_reporter/formatters/markdown.py | 6 +- redmine_reporter/formatters/odt.py | 5 +- redmine_reporter/formatters/task_table.py | 44 ++++ redmine_reporter/formatters/xlsx.py | 20 +- redmine_reporter/mailer.py | 9 +- redmine_reporter/report_builder.py | 32 +-- redmine_reporter/task_list.py | 124 ++++++++++ redmine_reporter/types.py | 28 ++- redmine_reporter/utils.py | 6 +- tests/test_cli.py | 153 +++++++++++- tests/test_client.py | 203 +++++++++++++++- tests/test_config.py | 25 +- tests/test_formatters.py | 4 +- tests/test_mailer.py | 27 ++- tests/test_task_list.py | 282 ++++++++++++++++++++++ tests/test_task_table.py | 160 ++++++++++++ tests/test_utils.py | 18 ++ tests/test_yaml_config.py | 1 - 27 files changed, 1255 insertions(+), 137 deletions(-) create mode 100644 redmine_reporter/formatters/task_table.py create mode 100644 redmine_reporter/task_list.py create mode 100644 tests/test_task_list.py create mode 100644 tests/test_task_table.py diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 4de9f70..81001e7 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -5,17 +5,18 @@ import re import sys from datetime import datetime from pathlib import Path -from typing import List, Optional import yaml from dotenv import find_dotenv, load_dotenv from . import __version__ -from .client import RedmineAPIError, fetch_issues_with_spent_time +from .client import RedmineAPIError, fetch_issues_with_spent_time, fetch_open_issues from .config import Config from .formatters.factory import get_console_formatter, get_formatter_by_extension +from .formatters.task_table import TaskTableFormatter from .mailer import send_report from .report_builder import build_grouped_report, calculate_summary +from .task_list import build_task_rows, calculate_task_summary, format_summary_line from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config # Маскируем API-ключ Redmine в тексте ошибок (#55): @@ -208,10 +209,9 @@ def _save_and_maybe_send( file=sys.stderr, ) else: - known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"]) print( f"❌ Неизвестный формат файла: {output_ext!r}. " - f"Поддерживаются: {known_exts}", + "Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx", file=sys.stderr, ) return 1, False @@ -219,7 +219,7 @@ def _save_and_maybe_send( try: formatter.save(rows, output_arg) print(f"✅ Report saved to {output_arg}") - except Exception as e: + except Exception as e: # noqa: BLE001 -- форматёры (odfpy/openpyxl/csv) бросают произвольные типы, CLI обязан вернуть код 1 на любой сбой fmt = output_ext.lstrip(".").upper() print(f"❌ {fmt} export error: {e}", file=sys.stderr) return 1, False @@ -264,7 +264,47 @@ def _save_and_maybe_send( return 0, False -def main(argv: Optional[List[str]] = None) -> int: +def _run_my_tasks(args: argparse.Namespace) -> int: + """Режим --my-tasks: таблица открытых задач пользователя в stdout (#71). + + Саммари печатается в stderr, а не в stdout: при перенаправлении вывода + в файл остаётся только таблица без служебных строк. + """ + try: + items = fetch_open_issues( + user_id=args.user_id or args.user_login or args.user_name + ) + except RedmineAPIError as e: + print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) + if (args.verbose or args.debug) and e.original is not None: + # Точка входа CLI: root-логгер осознанно настроен через basicConfig + # в main(), module-логгер здесь дал бы второй набор хендлеров. + logging.exception("Original Redmine API error") # noqa: LOG015 + return 1 + except Exception as e: + print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr) + if args.verbose or args.debug: + logging.exception("Unexpected error") # noqa: LOG015 + return 1 + + if not items: + print("ℹ️ Открытых задач нет.", file=sys.stderr) + return 0 + + rows = build_task_rows(items, Config.get_status_translation()) + summary = calculate_task_summary(items) + print(format_summary_line(summary), file=sys.stderr) + print(file=sys.stderr) + try: + table = TaskTableFormatter(base_url=Config.get_redmine_url()).format(rows) + except Exception as e: # noqa: BLE001 -- сбой рендера таблицы не должен ронять CLI трейсбеком: печатаем ошибку и возвращаем код 1 + print(f"❌ Formatting error: {e}", file=sys.stderr) + return 1 + print(table) + return 0 + + +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="redmine-reporter", description="Generate Redmine issue report based on your time entries.", @@ -349,6 +389,11 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date", ) + parser.add_argument( + "--my-tasks", + action="store_true", + help="List open issues assigned to you, sorted by urgency, and exit", + ) args = parser.parse_args(argv) # --init-config: обработка до всего остального @@ -374,6 +419,25 @@ def main(argv: Optional[List[str]] = None) -> int: return 1 return _run_init_config(args.config_path, args.force) + # --my-tasks: отдельный режим, отчётное окно и файлы к нему неприменимы + if args.my_tasks: + report_flags = [ + args.date is not None, + args.output, + args.compact, + args.summary, + args.no_time, + args.by_activity, + args.commit, + args.send, + ] + if any(report_flags): + print( + "❌ --my-tasks cannot be used with report-generating flags.", + file=sys.stderr, + ) + return 1 + # Валидация взаимоисключающих флагов пользователя user_args = [args.user_id, args.user_login, args.user_name] if sum(bool(a) for a in user_args) > 1: @@ -407,6 +471,10 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"❌ Configuration error: {e}", file=sys.stderr) return 1 + # Ветка --my-tasks: даты отчётного окна режиму не нужны. + if args.my_tasks: + return _run_my_tasks(args) + # Если --date не указан, используем дефолтный диапазон date_arg = args.date if args.date is not None else Config.get_default_date_range() try: @@ -425,12 +493,14 @@ def main(argv: Optional[List[str]] = None) -> int: except RedmineAPIError as e: print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) if (args.verbose or args.debug) and e.original is not None: - logging.exception("Original Redmine API error") + # Точка входа CLI: root-логгер осознанно настроен через basicConfig + # в main(), module-логгер здесь дал бы второй набор хендлеров. + logging.exception("Original Redmine API error") # noqa: LOG015 return 1 except Exception as e: print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr) if args.verbose or args.debug: - logging.exception("Unexpected error") + logging.exception("Unexpected error") # noqa: LOG015 return 1 if not issue_hours: @@ -554,7 +624,7 @@ def main(argv: Optional[List[str]] = None) -> int: try: output = formatter.format(rows) print(output) - except Exception as e: + except Exception as e: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1 print(f"❌ Formatting error: {e}", file=sys.stderr) return 1 @@ -569,7 +639,7 @@ def main(argv: Optional[List[str]] = None) -> int: try: save_period_to_config(args.config_path, from_str=next_from) - except Exception as e: + except Exception as e: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1 print( f"❌ Не удалось сохранить период в конфиг: {e}", file=sys.stderr, diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 9fb4da2..db88ec6 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,5 +1,6 @@ import sys -from typing import Any, Dict, List, Optional, Tuple, Union +from datetime import date +from typing import Any import requests import truststore @@ -20,13 +21,13 @@ ISSUE_ID_CHUNK_SIZE = 100 class RedmineAPIError(Exception): """Пользовательское исключение с понятным сообщением об ошибке Redmine API.""" - def __init__(self, message: str, original: Optional[Exception] = None): + def __init__(self, message: str, original: Exception | None = None): super().__init__(message) self.message = message self.original = original -def _get_redmine_auth_kwargs() -> Dict[str, Any]: +def _get_redmine_auth_kwargs() -> dict[str, Any]: """Return Redmine auth kwargs. API key has priority over legacy password auth.""" api_key = Config.get_redmine_api_key() if api_key: @@ -122,7 +123,7 @@ def _format_redmine_error(exc: Exception) -> str: return str(exc) -def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: +def _load_time_entry_activities(redmine: Redmine) -> dict[int, str]: """Загружает справочник типов активности time entries. Возвращает словарь id -> name. Если справочник недоступен, @@ -131,7 +132,7 @@ 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 as exc: + except Exception as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries # #61: не глотаем сбой молча — предупреждаем, что разбивка по # активностям будет построена по сырым данным из самих entries. print( @@ -142,7 +143,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: return {} -def _get_activity_name(entry, activities: Dict[int, str]) -> str: +def _get_activity_name(entry, activities: dict[int, str]) -> str: """Определяет название активности для time entry.""" activity = getattr(entry, "activity", None) if activity is None: @@ -162,9 +163,9 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str: return str(activity) -def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: +def _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" - all_issues: List[Issue] = [] + all_issues: list[Issue] = [] for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE): chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] issue_list_str = ",".join(str(x) for x in chunk) @@ -175,7 +176,7 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue] return all_issues -def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int: +def _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int: """Преобразует строковый идентификатор пользователя в числовой ID. Если аргумент — число, возвращает его как есть. @@ -248,9 +249,9 @@ def _get_current_user_id(redmine: Redmine) -> int: def fetch_issues_with_spent_time( from_date: str, to_date: str, - user_id: Optional[Union[int, str]] = None, + user_id: int | str | None = None, by_activity: bool = False, -) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: +) -> list[tuple[Issue, float, dict[str, float] | None]] | None: """ Fetch unique issues linked to time entries of the given user in date range, along with total spent hours per issue. @@ -281,8 +282,8 @@ def fetch_issues_with_spent_time( raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc # Агрегируем часы по issue.id (и активности, если требуется) - spent_time: Dict[int, float] = {} - spent_by_activity: Dict[int, Dict[str, float]] = {} + spent_time: dict[int, float] = {} + spent_by_activity: dict[int, dict[str, float]] = {} issue_ids = set() for entry in time_entries: if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): @@ -333,3 +334,66 @@ def fetch_issues_with_spent_time( result.append((issue, total_hours, activity_breakdown)) return result + + +def fetch_open_issues( + user_id: int | str | None = None, +) -> list[tuple[Issue, date | None]]: + """ + Fetch open issues assigned to the given user across all projects, + paired with the due date of each issue's fixed version. + If user_id is None, uses current user. + Returns list of (issue, due_date) tuples in API order; sorting is done + in the builder layer. Raises RedmineAPIError on API/auth/network failures. + """ + + try: + redmine = _create_redmine() + target_user_id = ( + _resolve_user_id(redmine, user_id) + if user_id is not None + else _get_current_user_id(redmine) + ) + # Без limit: python-redmine автопагинацией вычитывает все страницы, + # как в time_entry.filter выше. + issues = list( + redmine.issue.filter(assigned_to_id=target_user_id, status_id="open") + ) + except RedmineAPIError: + raise + except Exception as exc: + raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc + + # fixed_version внутри задачи содержит только id/name, поэтому due_date + # приходится добирать отдельным запросом на каждую уникальную версию (#71). + # Отказ загрузки одной версии не валидует выгрузку: её задачи получат None. + due_by_version: dict[int, date | None] = {} + for issue in issues: + version_ref = getattr(issue, "fixed_version", None) + if version_ref is None: + continue + version_id = getattr(version_ref, "id", None) + if version_id is None or version_id in due_by_version: + continue + try: + version = redmine.version.get(version_id) + except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None + print( + f"⚠️ Could not load version {version_id}: {exc}. " + "Due date will be empty for its issues.", + file=sys.stderr, + ) + due_by_version[version_id] = None + else: + due_by_version[version_id] = getattr(version, "due_date", None) + + # Порядок задач сохраняем как в ответе API. + result: list[tuple[Issue, date | None]] = [] + for issue in issues: + version_ref = getattr(issue, "fixed_version", None) + version_id = ( + getattr(version_ref, "id", None) if version_ref is not None else None + ) + due = due_by_version.get(version_id) if version_id is not None else None + result.append((issue, due)) + return result diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 2186728..fb46dc0 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -4,7 +4,6 @@ import sys from dataclasses import dataclass, field from datetime import date from pathlib import Path -from typing import Dict, Union import yaml from dotenv import load_dotenv @@ -13,7 +12,7 @@ from .yaml_config import check_file_permissions, resolve_env_vars logger = logging.getLogger(__name__) -DEFAULT_REDMINE_VERIFY: Union[bool, str] = True +DEFAULT_REDMINE_VERIFY: bool | str = True FALSE_VALUES = {"0", "false", "no", "off"} TRUE_VALUES = {"1", "true", "yes", "on"} @@ -45,17 +44,17 @@ class AppConfig: redmine_url: str = "" redmine_api_key: str = "" redmine_author: str = "" - redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY + redmine_verify: bool | str = DEFAULT_REDMINE_VERIFY period_from: str = "" output_dir: str = "" output_filename: str = "{author}_{from}_{to}.{ext}" output_default_format: str = "xlsx" report_no_time: bool = False - report_status_translation: Dict[str, str] = field(default_factory=dict) + report_status_translation: dict[str, str] = field(default_factory=dict) email: EmailConfig = field(default_factory=EmailConfig) @classmethod - def from_yaml(cls, path: Union[str, Path]) -> "AppConfig": + def from_yaml(cls, path: str | Path) -> "AppConfig": """Загружает настройки из YAML-файла. Если файл не существует — возвращает конфиг со значениями по умолчанию. @@ -118,7 +117,7 @@ class AppConfig: return str(value) @staticmethod - def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]: + def _resolve_str_dict(raw: dict, section: str, key: str) -> dict[str, str]: """Читает вложенный mapping строк из YAML-секции. Не-mapping значение логируется с warning и игнорируется. @@ -140,7 +139,7 @@ class AppConfig: type(value).__name__, ) return {} - result: Dict[str, str] = {} + result: dict[str, str] = {} for k, v in value.items(): if v is None or isinstance(v, (dict, list)): logger.warning( @@ -168,7 +167,7 @@ class AppConfig: return False @classmethod - def _resolve_verify(cls, raw: dict) -> Union[bool, str]: + def _resolve_verify(cls, raw: dict) -> bool | str: """Семантика verify_ssl, единая с REDMINE_VERIFY из env: - true (bool или строка) → True — стандартная проверка TLS (requests); @@ -314,7 +313,7 @@ class Config: return os.getenv("REDMINE_PASSWORD", "").strip() @classmethod - def get_redmine_verify(cls) -> Union[bool, str]: + def get_redmine_verify(cls) -> bool | str: value = os.getenv("REDMINE_VERIFY", "").strip() if value: normalized = value.lower() @@ -364,7 +363,7 @@ class Config: return False @classmethod - def get_status_translation(cls) -> Dict[str, str]: + def get_status_translation(cls) -> dict[str, str]: """Полный словарь перевода статусов: встроенный + переопределения из YAML. Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется. @@ -413,8 +412,7 @@ class Config: # Будущая start инвертирует диапазон и ломает фильтр Redmine, # поэтому сжимаем окно до сегодняшнего дня. - if start > today: - start = today + start = min(start, today) return f"{start.isoformat()}--{today.isoformat()}" @classmethod diff --git a/redmine_reporter/formatters/base.py b/redmine_reporter/formatters/base.py index 89ee287..0ea4adf 100644 --- a/redmine_reporter/formatters/base.py +++ b/redmine_reporter/formatters/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, List +from typing import Any from ..types import ReportRow @@ -16,19 +16,17 @@ class Formatter(ABC): """ @abstractmethod - def format(self, rows: List[ReportRow]) -> Any: + def format(self, rows: list[ReportRow]) -> Any: """ Форматирует список строк отчета в нужный формат. Для текстовых форматтеров возвращает str. Для ODTFormatter возвращает объект OpenDocument. """ - pass @abstractmethod - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: """ Сохраняет отформатированный отчет в файл по указанному пути. Для форматтеров, которые не поддерживают сохранение (например, консольные), бросает NotImplementedError. """ - pass diff --git a/redmine_reporter/formatters/console.py b/redmine_reporter/formatters/console.py index a009530..885764d 100644 --- a/redmine_reporter/formatters/console.py +++ b/redmine_reporter/formatters/console.py @@ -1,5 +1,3 @@ -from typing import List - from tabulate import tabulate from ..types import ReportRow @@ -9,7 +7,7 @@ from .base import Formatter class TableFormatter(Formatter): """Форматтер для вывода красивой таблицы в консоль.""" - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] for r in rows: time_text = r["time_text"].replace("\n", " / ") @@ -24,7 +22,7 @@ class TableFormatter(Formatter): ) return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: # Консольные форматтеры не умеют сохранять в файл напрямую. # Это делается в CLI. raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") @@ -33,7 +31,7 @@ class TableFormatter(Formatter): class CompactFormatter(Formatter): """Форматтер для компактного вывода в консоль.""" - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: lines = [] for r in rows: time_text = r["time_text"].replace("\n", " / ") @@ -43,7 +41,7 @@ class CompactFormatter(Formatter): ) return "\n".join(lines) - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: # Консольные форматтеры не умеют сохранять в файл напрямую. # Это делается в CLI. raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.") diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index 5f24ca6..18673da 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -1,6 +1,5 @@ import csv import io -from typing import List from ..types import ReportRow from .base import Formatter @@ -19,7 +18,7 @@ class CSVFormatter(Formatter): super().__init__() self.no_time = no_time - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: output = io.StringIO() writer = csv.writer(output, dialect="excel") headers = ["Project", "Version", "Issue ID", "Subject", "Status"] @@ -40,7 +39,7 @@ class CSVFormatter(Formatter): writer.writerow(data) return output.getvalue() - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: content = self.format(rows) with open(output_path, "w", encoding="utf-8-sig", newline="") as f: f.write(content) diff --git a/redmine_reporter/formatters/factory.py b/redmine_reporter/formatters/factory.py index 720bdcc..a5e980f 100644 --- a/redmine_reporter/formatters/factory.py +++ b/redmine_reporter/formatters/factory.py @@ -1,5 +1,3 @@ -from typing import Dict, Optional, Type - from .base import Formatter from .console import CompactFormatter, TableFormatter from .csv import CSVFormatter @@ -11,7 +9,7 @@ from .xlsx import XLSXFormatter # Словарь для сопоставления расширений файлов с классами форматтеров. # ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как # odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension. -FORMATTER_MAP: Dict[str, Type[Formatter]] = { +FORMATTER_MAP: dict[str, type[Formatter]] = { ".csv": CSVFormatter, ".json": JSONFormatter, ".md": MarkdownFormatter, @@ -21,13 +19,13 @@ FORMATTER_MAP: Dict[str, Type[Formatter]] = { # Словарь для сопоставления типа вывода (консоль) с классами форматтеров -CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = { +CONSOLE_FORMATTER_MAP: dict[str, type[Formatter]] = { "table": TableFormatter, "compact": CompactFormatter, } -def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: +def get_formatter_by_extension(extension: str, **kwargs) -> Formatter | None: """ Возвращает экземпляр форматтера по расширению файла. Ключевые аргументы (**kwargs) передаются в конструктор форматтера. @@ -51,7 +49,7 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: return None -def get_console_formatter(formatter_type: str) -> Optional[Formatter]: +def get_console_formatter(formatter_type: str) -> Formatter | None: """ Возвращает экземпляр консольного форматтера по его типу. """ diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 448462b..1b459ee 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -1,5 +1,4 @@ from html import escape -from typing import List from ..report_builder import group_rows_by_project_and_version from ..types import ReportRow @@ -12,7 +11,7 @@ class HTMLFormatter(Formatter): def __init__(self, **_kwargs): super().__init__() - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: projects = group_rows_by_project_and_version(rows) lines = [ @@ -79,7 +78,7 @@ class HTMLFormatter(Formatter): lines.append("") return "\n".join(lines) - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: content = self.format(rows) with open(output_path, "w", encoding="utf-8") as f: f.write(content) diff --git a/redmine_reporter/formatters/json.py b/redmine_reporter/formatters/json.py index f1049b5..74ff539 100644 --- a/redmine_reporter/formatters/json.py +++ b/redmine_reporter/formatters/json.py @@ -1,5 +1,4 @@ import json -from typing import List from ..types import ReportRow from .base import Formatter @@ -11,7 +10,7 @@ class JSONFormatter(Formatter): def __init__(self, **_kwargs): super().__init__() - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: data = [] for r in rows: item = { @@ -28,7 +27,7 @@ class JSONFormatter(Formatter): data.append(item) return json.dumps(data, ensure_ascii=False, indent=2) - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: content = self.format(rows) with open(output_path, "w", encoding="utf-8") as f: f.write(content) diff --git a/redmine_reporter/formatters/markdown.py b/redmine_reporter/formatters/markdown.py index ca886a7..a9c9ca9 100644 --- a/redmine_reporter/formatters/markdown.py +++ b/redmine_reporter/formatters/markdown.py @@ -1,5 +1,3 @@ -from typing import List - from ..types import ReportRow from .base import Formatter @@ -14,7 +12,7 @@ class MarkdownFormatter(Formatter): def __init__(self, **_kwargs): super().__init__() - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: list[ReportRow]) -> str: lines = [ "| Проект | Версия | Задача | Статус | Затрачено |", "|--------|--------|--------|--------|-----------|", @@ -30,7 +28,7 @@ class MarkdownFormatter(Formatter): ) return "\n".join(lines) - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: content = self.format(rows) with open(output_path, "w", encoding="utf-8") as f: f.write(content) diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 8eb7acd..2313037 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -1,5 +1,4 @@ from importlib import resources -from typing import List from odf.opendocument import OpenDocument, load from odf.style import Style, TableCellProperties, TableColumnProperties @@ -25,7 +24,7 @@ class ODTFormatter(Formatter): self.from_date = from_date self.to_date = to_date - def format(self, rows: List[ReportRow]) -> OpenDocument: + def format(self, rows: list[ReportRow]) -> OpenDocument: """ Форматирует данные в объект OpenDocument. """ @@ -172,7 +171,7 @@ class ODTFormatter(Formatter): return doc - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: """ Сохраняет сформированный документ в файл. """ diff --git a/redmine_reporter/formatters/task_table.py b/redmine_reporter/formatters/task_table.py new file mode 100644 index 0000000..43515b3 --- /dev/null +++ b/redmine_reporter/formatters/task_table.py @@ -0,0 +1,44 @@ +from tabulate import tabulate + +from ..types import TaskRow + + +class TaskTableFormatter: + """Консольный форматтер таблицы открытых задач пользователя (режим --my-tasks). + + Не входит в фабрику файловых форматтеров: таблица всегда печатается + в stdout, поэтому сохранение в файл не поддержано. + """ + + def __init__(self, base_url: str) -> None: + self.base_url = base_url + + def _hyperlink(self, issue_id: int) -> str: + # OSC 8-гиперссылка: номер задачи остаётся видимым терминалом без + # поддержки ссылок, а поддерживающие делают его кликабельным. + url = f"{self.base_url.rstrip('/')}/issues/{issue_id}" + return f"\x1b]8;;{url}\x1b\\{issue_id}\x1b]8;;\x1b\\" + + def format(self, rows: list[TaskRow]) -> str: + table_rows = [["ID", "Тема", "Статус", "Приор.", "Версия", "Срок"]] + for row in rows: + due_text = row["due_text"] + if row["overdue"]: + due_text = f"{due_text} (!)" + table_rows.append( + [ + self._hyperlink(row["issue_id"]), + row["subject"], + row["status_ru"], + row["priority"], + row["version_name"] or "-", + due_text or "-", + ] + ) + return tabulate(table_rows, headers="firstrow", tablefmt="simple") + + def save(self, rows: list[TaskRow], output_path: str) -> None: + # Консольные форматтеры не умеют сохранять в файл напрямую. + raise NotImplementedError( + "TaskTableFormatter не поддерживает сохранение в файл." + ) diff --git a/redmine_reporter/formatters/xlsx.py b/redmine_reporter/formatters/xlsx.py index 16c4dd6..c75b703 100644 --- a/redmine_reporter/formatters/xlsx.py +++ b/redmine_reporter/formatters/xlsx.py @@ -1,5 +1,3 @@ -from typing import Dict, List, Tuple - from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter @@ -36,7 +34,7 @@ class XLSXFormatter(Formatter): super().__init__() self.no_time = no_time - def format(self, rows: List[ReportRow]) -> Workbook: + def format(self, rows: list[ReportRow]) -> Workbook: wb = Workbook() ws = wb.active ws.title = "Report" @@ -56,9 +54,9 @@ class XLSXFormatter(Formatter): grouped = group_rows_by_project_and_version(rows) current_row = 2 - project_ranges: List[Tuple[int, int]] = [] - version_ranges: List[Tuple[int, int]] = [] - project_totals: Dict[str, float] = {} + project_ranges: list[tuple[int, int]] = [] + version_ranges: list[tuple[int, int]] = [] + project_totals: dict[str, float] = {} for project, versions in grouped.items(): project_start_row = current_row @@ -175,11 +173,11 @@ class XLSXFormatter(Formatter): return wb - def save(self, rows: List[ReportRow], output_path: str) -> None: + def save(self, rows: list[ReportRow], output_path: str) -> None: wb = self.format(rows) wb.save(output_path) - def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None: + def _style_header_row(self, ws: Worksheet, headers: list[str]) -> None: for col_idx, _ in enumerate(headers, start=1): cell = ws.cell(row=1, column=col_idx) cell.font = Font(bold=True) @@ -193,9 +191,7 @@ class XLSXFormatter(Formatter): for col_idx in range(1, 8): cell = ws.cell(row=row, column=col_idx) cell.border = self._BORDER - if col_idx in (1, 2): - cell.alignment = Alignment(vertical="top", wrap_text=True) - elif col_idx == 4: + if col_idx in (1, 2) or col_idx == 4: cell.alignment = Alignment(vertical="top", wrap_text=True) else: cell.alignment = Alignment(vertical="top") @@ -210,7 +206,7 @@ class XLSXFormatter(Formatter): def _apply_column_widths(self, ws: Worksheet) -> None: # Минимальные ширины по умолчанию - widths: Dict[int, float] = { + widths: dict[int, float] = { 1: 18.0, 2: 16.0, 3: 12.0, diff --git a/redmine_reporter/mailer.py b/redmine_reporter/mailer.py index ae28e99..e901f93 100644 --- a/redmine_reporter/mailer.py +++ b/redmine_reporter/mailer.py @@ -5,7 +5,6 @@ import smtplib from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from typing import Dict, List from .client import RedmineAPIError from .config import EmailConfig @@ -13,7 +12,7 @@ from .types import ReportRow SMTP_TIMEOUT = 30 -MIME_TYPES: Dict[str, str] = { +MIME_TYPES: dict[str, str] = { ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".odt": "application/vnd.oasis.opendocument.text", ".csv": "text/csv", @@ -43,7 +42,7 @@ def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText: return part -def _build_html_body(rows: List[ReportRow]) -> str: +def _build_html_body(rows: list[ReportRow]) -> str: """Генерирует HTML-версию тела письма через HTMLFormatter.""" from .formatters.html import HTMLFormatter @@ -56,7 +55,7 @@ def build_message( file_path: str, author: str, period: str, - rows: List[ReportRow], + rows: list[ReportRow], ) -> MIMEMultipart: """Формирует MIME-письмо с подстановками, телами и вложением.""" subject = email_config.subject.replace("{author}", author).replace( @@ -108,7 +107,7 @@ def send_report( file_path: str, author: str, period: str, - rows: List[ReportRow], + rows: list[ReportRow], ) -> None: """Отправляет сгенерированный отчёт по email через SMTP. diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index d60dd38..fc50507 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Tuple, cast +from typing import cast from redminelib.resources import Issue @@ -22,7 +22,7 @@ STATUS_TRANSLATION = { } -def _format_activities(activities: Dict[str, float]) -> str: +def _format_activities(activities: dict[str, float]) -> str: """Форматирует разбивку по активностям в многострочный текст.""" if not activities: return "" @@ -32,11 +32,11 @@ def _format_activities(activities: Dict[str, float]) -> str: def build_grouped_report( - issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]], + issue_hours: list[tuple[Issue, float, dict[str, float] | None]], fill_time: bool = True, by_activity: bool = False, - status_translation: Optional[Dict[str, str]] = None, -) -> List[ReportRow]: + status_translation: dict[str, str] | None = None, +) -> list[ReportRow]: """ Преобразует список задач с затраченным временем в плоский список строк отчёта, с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений). @@ -59,12 +59,12 @@ def build_grouped_report( status_translation if status_translation is not None else STATUS_TRANSLATION ) - rows: List[ReportRow] = [] + rows: list[ReportRow] = [] prev_project: str = "" prev_version: str = "" for issue, hours, *rest in issue_hours: - activities: Optional[Dict[str, float]] = rest[0] if rest else None + activities: dict[str, float] | None = rest[0] if rest else None project = str(issue.project) version = get_version(issue) status_en = str(issue.status) @@ -108,14 +108,14 @@ def build_grouped_report( def calculate_summary( - rows: List[ReportRow], + rows: list[ReportRow], by_activity: bool = False, -) -> Dict[str, float]: +) -> dict[str, float]: """Возвращает сводку: общее время, время по проектам, версиям и активностям.""" total = 0.0 - by_project: Dict[str, float] = {} - by_project_version: Dict[str, float] = {} - by_activity_name: Dict[str, float] = {} + by_project: dict[str, float] = {} + by_project_version: dict[str, float] = {} + by_activity_name: dict[str, float] = {} for r in rows: hours = r.get("hours", 0.0) @@ -130,7 +130,7 @@ def calculate_summary( for name, act_hours in activities.items(): by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours - result: Dict[str, float] = { + result: dict[str, float] = { "total": round(total, 2), **{f"project:{k}": round(v, 2) for k, v in by_project.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, @@ -144,8 +144,8 @@ def calculate_summary( def group_rows_by_project_and_version( - rows: List[ReportRow], -) -> Dict[str, Dict[str, List[ReportRow]]]: + rows: list[ReportRow], +) -> dict[str, dict[str, list[ReportRow]]]: """ Группирует плоский список строк отчёта в иерархию project → version → [rows]. @@ -153,7 +153,7 @@ def group_rows_by_project_and_version( Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+). Используется форматтерами HTML и ODT для объединения ячеек. """ - projects: Dict[str, Dict[str, List[ReportRow]]] = {} + projects: dict[str, dict[str, list[ReportRow]]] = {} for r in rows: project = r["project"] version = r["version"] diff --git a/redmine_reporter/task_list.py b/redmine_reporter/task_list.py new file mode 100644 index 0000000..f5055e9 --- /dev/null +++ b/redmine_reporter/task_list.py @@ -0,0 +1,124 @@ +from datetime import date + +from redminelib.resources import Issue + +from .types import TaskRow, TaskSummary +from .utils import get_version + +# В этом Redmine ровно пять приоритетов P1..P5; меньший номер -- срочнее. +PRIORITY_RANK: dict[str, int] = {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5} + +# Ранг для неизвестного имени приоритета: такие задачи уходят в самый конец. +LOWEST_PRIORITY_RANK = len(PRIORITY_RANK) + 1 + + +def priority_rank(name: str) -> int: + """Возвращает ранг приоритета; неизвестное имя получает низший ранг.""" + return PRIORITY_RANK.get(name, LOWEST_PRIORITY_RANK) + + +def _issue_version_name(issue: Issue) -> str: + """Имя версии задачи или пустая строка, если версия не назначена.""" + return get_version(issue, default="") + + +def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]: + """Ключ сортировки: приоритет, затем срок по возрастанию, затем id. + + Задачи без срока уходят в конец своей приоритетной группы: флаг + (due is None) сортирует False раньше True, а date.max служит нейтральным + значением, чтобы сравнение дат осталось корректным. + """ + issue, due = item + rank = priority_rank(str(issue.priority.name)) + return (rank, due is None, due if due is not None else date.max, issue.id) + + +def build_task_rows( + items: list[tuple[Issue, date | None]], + status_translation: dict[str, str], + today: date | None = None, +) -> list[TaskRow]: + """Строит отсортированный список строк режима --my-tasks. + + items -- пары (задача, срок версии или None). today параметризован + для тестируемости; по умолчанию берётся текущая дата. + """ + if today is None: + today = date.today() + + rows: list[TaskRow] = [] + for issue, due in sorted(items, key=_sort_key): + status_name = str(issue.status) + rows.append( + { + "issue_id": issue.id, + "subject": str(issue.subject), + "status_ru": status_translation.get(status_name, status_name), + "priority": str(issue.priority.name), + "version_name": _issue_version_name(issue), + "due_text": due.isoformat() if due is not None else "", + "overdue": due is not None and due < today, + } + ) + return rows + + +def calculate_task_summary( + items: list[tuple[Issue, date | None]], + today: date | None = None, +) -> TaskSummary: + """Считает саммари по списку пар (задача, срок версии или None).""" + if today is None: + today = date.today() + + overdue = 0 + no_version = 0 + no_due = 0 + spent_hours = 0.0 + estimated_hours = 0.0 + estimated_count = 0 + + for issue, due in items: + if due is not None and due < today: + overdue += 1 + + version = getattr(issue, "fixed_version", None) + if version is None: + no_version += 1 + elif due is None: + no_due += 1 + + raw_spent = getattr(issue, "spent_hours", None) + if raw_spent is not None: + spent_hours += float(raw_spent) + + raw_estimated = getattr(issue, "estimated_hours", None) + if raw_estimated is not None: + estimated_hours += float(raw_estimated) + estimated_count += 1 + + return { + "total": len(items), + "overdue": overdue, + "no_version": no_version, + "no_due": no_due, + "spent_hours": spent_hours, + "estimated_hours": estimated_hours, + "estimated_count": estimated_count, + } + + +def format_summary_line(s: TaskSummary) -> str: + """Форматирует однострочное саммари; блок оценки выводится при наличии данных.""" + line = ( + f"Открытых: {s['total']} | просроченных: {s['overdue']}" + f" | без версии: {s['no_version']} | без срока: {s['no_due']}" + f" | потрачено: {s['spent_hours']:g}h" + ) + if s["estimated_count"] > 0: + line += ( + f" | оценка: {s['estimated_hours']:g}h" + f" (по {s['estimated_count']} из {s['total']})" + ) + return line diff --git a/redmine_reporter/types.py b/redmine_reporter/types.py index 99e9132..816e29f 100644 --- a/redmine_reporter/types.py +++ b/redmine_reporter/types.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, TypedDict +from typing import TypedDict class ReportRowBase(TypedDict): @@ -18,4 +18,28 @@ class ReportRowBase(TypedDict): class ReportRow(ReportRowBase, total=False): """Строка итогового отчёта с опциональной разбивкой по активностям.""" - activities: Optional[Dict[str, float]] + activities: dict[str, float] | None + + +class TaskRow(TypedDict): + """Строка списка открытых задач пользователя (режим --my-tasks).""" + + issue_id: int + subject: str + status_ru: str + priority: str + version_name: str + due_text: str + overdue: bool + + +class TaskSummary(TypedDict): + """Саммари списка открытых задач пользователя.""" + + total: int + overdue: int + no_version: int + no_due: int + spent_hours: float + estimated_hours: float + estimated_count: int diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index 3501afd..dc9b7cb 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -28,11 +28,11 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str: return months[end.month] -def get_version(issue) -> str: - """Возвращает версию задачи или '', если не задана.""" +def get_version(issue, default: str = "") -> str: + """Возвращает версию задачи или default, если не задана.""" version = getattr(issue, "fixed_version", None) if version is None: - return "" + return default name = getattr(version, "name", None) return str(name) if name else str(version) diff --git a/tests/test_cli.py b/tests/test_cli.py index ebd9f7f..ff67bc7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,10 @@ import os +from datetime import date from unittest import mock import pytest - from redmine_reporter.cli import main, parse_date_range +from redmine_reporter.client import RedmineAPIError VALID_ENV = { "REDMINE_URL": "https://red.eltex.loc", @@ -1997,3 +1998,153 @@ class TestSendConfirmation: assert "bcc@example.com" in captured.out assert output in captured.out mock_input.assert_called_once_with("Отправить? [y/N]: ") + + +# --------------------------------------------------------------------------- +# #71: --my-tasks tests +# --------------------------------------------------------------------------- + + +def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"): + """Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима. + + build_task_rows читает nested-атрибуты (priority.name); unset-атрибут + MagicMock был бы автосконструирован и str() дал бы нестабильный текст. + """ + issue = mock.MagicMock() + issue.id = issue_id + issue.subject = subject + issue.status = status + issue.priority.name = priority + issue.fixed_version = None + issue.spent_hours = 0.0 + issue.estimated_hours = None + return issue + + +def _expected_link(issue_id): + """Точная OSC 8-последовательность для ссылки на задачу в stdout.""" + return ( + f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\" + ) + + +class TestMyTasksFlag: + """Tests for --my-tasks flag (#71).""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @pytest.mark.parametrize( + "flag_args", + [ + ["--date", "2026-01-01--2026-01-31"], + ["--output", "/tmp/report.xlsx"], + ["--compact"], + ["--summary"], + ["--by-activity"], + ["--no-time"], + ["--commit"], + ["--send"], + ], + ) + def test_my_tasks_rejects_report_flags(self, flag_args, capsys): + """--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение.""" + code = main(["--my-tasks"] + flag_args) + captured = capsys.readouterr() + assert code == 1 + assert "--my-tasks cannot be used with report-generating flags" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys): + """Пустой список открытых задач -- выход 0 и сообщение в stderr.""" + mock_fetch.return_value = [] + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + assert "Открытых задач нет" in captured.err + assert captured.out == "" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_passes_user_id(self, mock_fetch): + """--user-id передаётся в fetch_open_issues.""" + mock_fetch.return_value = [] + code = main(["--my-tasks", "--user-id", "42"]) + assert code == 0 + _, kwargs = mock_fetch.call_args + assert kwargs["user_id"] == "42" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_no_user_flag_passes_none(self, mock_fetch): + """Без флагов пользователя в fetch_open_issues уходит user_id=None.""" + mock_fetch.return_value = [] + code = main(["--my-tasks"]) + assert code == 0 + _, kwargs = mock_fetch.call_args + assert kwargs["user_id"] is None + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_happy_path(self, mock_fetch, capsys): + """Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr.""" + issue_overdue = _make_task_issue(42, subject="Починить баг") + issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending") + # Срок глубоко в прошлом: overdue не зависит от даты прогона тестов. + mock_fetch.return_value = [ + (issue_fresh, None), + (issue_overdue, date(2000, 1, 1)), + ] + + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + + # Ссылка на base_url из env теста с точной escape-последовательностью. + assert _expected_link(42) in captured.out + assert _expected_link(43) in captured.out + + # Переведённые статусы попадают в таблицу. + assert "В работе" in captured.out + assert "Ожидание" in captured.out + assert "Починить баг" in captured.out + + # Саммари -- в stderr, таблица -- не там. + assert "Открытых: 2" in captured.err + assert "просроченных: 1" in captured.err + assert "потрачено:" in captured.err + assert "Открытых:" not in captured.out + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys): + """RedmineAPIError -- понятное сообщение и выход 1.""" + mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key") + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 1 + assert "Authentication failed" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys): + """Непредвиденное исключение -- Unexpected error и выход 1.""" + mock_fetch.side_effect = RuntimeError("boom") + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 1 + assert "Unexpected error" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.TaskTableFormatter") + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_format_error_exits_one( + self, mock_fetch, mock_formatter_cls, capsys + ): + """Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека.""" + mock_fetch.return_value = [(_make_task_issue(7), None)] + mock_formatter_cls.return_value.format.side_effect = ValueError("boom") + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 1 + assert "❌ Formatting error: boom" in captured.err diff --git a/tests/test_client.py b/tests/test_client.py index e8566f0..e8f005b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py index fe5b79e..a6d0afe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,10 +2,10 @@ import contextlib import os import tempfile from pathlib import Path +from typing import ClassVar from unittest import mock import pytest - from redmine_reporter.config import AppConfig, Config, EmailConfig @@ -194,9 +194,8 @@ class TestDefaultDateRangePeriodFrom: yaml_path.write_text("period:\n from: 'not-a-date'\n") Config._app = AppConfig.from_yaml(yaml_path) - with caplog.at_level(logging.WARNING): - with mock_today(date(2026, 9, 15)): - rng = Config.get_default_date_range() + with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)): + rng = Config.get_default_date_range() assert rng == "2026-09-01--2026-09-15" assert "not-a-date" in caplog.text @@ -208,9 +207,8 @@ class TestDefaultDateRangePeriodFrom: from datetime import date Config._app = None - with caplog.at_level(logging.WARNING): - with mock_today(date(2026, 9, 15)): - rng = Config.get_default_date_range() + with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)): + rng = Config.get_default_date_range() assert rng == "2026-09-01--2026-09-15" assert "garbage" in caplog.text @@ -296,7 +294,10 @@ class TestConfigRequiresHttpsUrl: class TestWarnWhenTlsVerificationDisabled: """verify_ssl: false должен давать видимый warning в stderr (риск MITM).""" - _ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"} + _ENV: ClassVar[dict[str, str]] = { + "REDMINE_URL": "https://red.eltex.loc/", + "REDMINE_API_KEY": "token", + } def _validate_env(self, verify: str): return {**self._ENV, "REDMINE_VERIFY": verify} @@ -661,9 +662,11 @@ class TestConfigYamlFallback: Config._app = AppConfig.from_yaml(yaml_path) - with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True): - with mock_today(date(2026, 7, 15)): - assert Config.get_default_date_range() == "2026-07-01--2026-07-15" + with ( + mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True), + mock_today(date(2026, 7, 15)), + ): + assert Config.get_default_date_range() == "2026-07-01--2026-07-15" class TestGetEmailConfig: diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 05f5ffe..b5d42a0 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -1,11 +1,9 @@ import io import json -from typing import List from unittest import mock import pytest from odf.opendocument import OpenDocument, OpenDocumentText - from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.factory import get_formatter_by_extension @@ -25,7 +23,7 @@ def _make_empty_odt_bytes() -> bytes: return buf.getvalue() -def make_fake_report_rows() -> List[ReportRow]: +def make_fake_report_rows() -> list[ReportRow]: """ Генерирует фейковый отчёт с полным покрытием логики группировки: - Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача) diff --git a/tests/test_mailer.py b/tests/test_mailer.py index 91d343f..ec19ebc 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -2,7 +2,6 @@ import smtplib from unittest import mock import pytest - from redmine_reporter.client import RedmineAPIError from redmine_reporter.config import EmailConfig, SmtpConfig from redmine_reporter.mailer import build_message, send_report @@ -65,7 +64,7 @@ class TestBuildMessage: data = buf.getvalue() assert b"Content-Transfer-Encoding: 8bit" in data - assert "Автор: Иванов".encode("utf-8") in data + assert "Автор: Иванов".encode() in data def test_plain_part_uses_8bit_utf8(self): """Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть.""" @@ -74,7 +73,7 @@ class TestBuildMessage: ) msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", []) - plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0] + plain = next(p for p in msg.walk() if p.get_content_type() == "text/plain") assert plain["Content-Transfer-Encoding"] == "8bit" assert ( plain.get_payload(decode=True).decode("utf-8") @@ -205,7 +204,7 @@ class TestBuildMessage: ] msg = build_message(cfg, str(report), "A", "P", rows) - html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0] + html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html") payload = html_part.get_payload(decode=True).decode("utf-8") assert "Project X" in payload assert "Important task" in payload @@ -228,9 +227,9 @@ class TestBuildMessage: ] msg = build_message(cfg, str(report), "A", "P", rows) - alternative = [ + alternative = next( p for p in msg.walk() if p.get_content_type() == "multipart/alternative" - ][0] + ) payloads = alternative.get_payload() assert payloads[0].get_content_type() == "text/plain" assert payloads[1].get_content_type() == "text/html" @@ -273,9 +272,11 @@ class TestSendReport: report.write_text("fake content") cfg = _make_email_config() - with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")): - with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"): - send_report(cfg, str(report), "A", "P", []) + with ( + mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")), + pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"), + ): + send_report(cfg, str(report), "A", "P", []) def test_send_auth_error(self, tmp_path): report = tmp_path / "report.xlsx" @@ -297,9 +298,11 @@ class TestSendReport: report.write_text("fake content") cfg = _make_email_config() - with mock.patch("smtplib.SMTP", side_effect=TimeoutError()): - with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"): - send_report(cfg, str(report), "A", "P", []) + with ( + mock.patch("smtplib.SMTP", side_effect=TimeoutError()), + pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"), + ): + send_report(cfg, str(report), "A", "P", []) def test_send_generic_smtp_error(self, tmp_path): report = tmp_path / "report.xlsx" diff --git a/tests/test_task_list.py b/tests/test_task_list.py new file mode 100644 index 0000000..e06aad5 --- /dev/null +++ b/tests/test_task_list.py @@ -0,0 +1,282 @@ +"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари.""" + +from datetime import date +from unittest import mock + +from redmine_reporter.task_list import ( + PRIORITY_RANK, + build_task_rows, + calculate_task_summary, + format_summary_line, + priority_rank, +) + +TODAY = date(2026, 10, 28) + + +def make_issue( + issue_id, + priority="P3", + status="New", + fixed_version=None, + subject="Задача", + due=None, + spent_hours=0.0, + estimated_hours=None, +): + """Собирает mock-issue с nested-атрибутами priority.name / fixed_version.name. + + fixed_version=None присваивается явно: unset-атрибут MagicMock был бы + автосконструирован, и getattr() вернул бы его вместо безопасного дефолта. + """ + issue = mock.MagicMock() + issue.id = issue_id + issue.subject = subject + issue.status = status + issue.priority.name = priority + if fixed_version is None: + issue.fixed_version = None + else: + issue.fixed_version.name = fixed_version + issue.spent_hours = spent_hours + # None присваивается явно, чтобы getattr() в реализации не наткнулся + # на автосконструированный MagicMock-атрибут. + issue.estimated_hours = estimated_hours + return issue + + +# -- Приоритеты -- + + +def test_priority_rank_table(): + assert PRIORITY_RANK == {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5} + + +def test_priority_rank_known(): + assert priority_rank("P1") == 1 + assert priority_rank("P5") == 5 + + +def test_priority_rank_unknown_is_lowest(): + """Неизвестное имя приоритета получает ранг ниже P5.""" + assert priority_rank("P9") == 6 + assert priority_rank("") == 6 + + +def test_rows_sorted_by_priority(): + """P1 раньше P2 раньше P4; неизвестный P9 -- после P5.""" + items = [ + (make_issue(4, priority="P4"), None), + (make_issue(9, priority="P9"), None), + (make_issue(2, priority="P2"), None), + (make_issue(5, priority="P5"), None), + (make_issue(1, priority="P1"), None), + ] + rows = build_task_rows(items, {}, today=TODAY) + assert [r["issue_id"] for r in rows] == [1, 2, 4, 5, 9] + + +# -- Сортировка внутри приоритетной группы -- + + +def test_rows_due_ascending_within_priority(): + items = [ + (make_issue(2, priority="P2"), date(2026, 3, 10)), + (make_issue(1, priority="P2"), date(2026, 1, 1)), + (make_issue(3, priority="P2"), date(2026, 2, 15)), + ] + rows = build_task_rows(items, {}, today=TODAY) + assert [r["issue_id"] for r in rows] == [1, 3, 2] + + +def test_rows_without_due_go_last_and_tiebreak_by_id(): + """Задачи без срока -- в конец своей группы; между ними tie-break по id.""" + items = [ + (make_issue(25, fixed_version="v2"), None), # версия есть, срока нет + (make_issue(30, fixed_version="v1"), date(2026, 2, 1)), + (make_issue(12), None), # версии нет, срока нет + ] + rows = build_task_rows(items, {}, today=TODAY) + assert [r["issue_id"] for r in rows] == [30, 12, 25] + + +# -- Просроченность -- + + +def test_overdue_yesterday(): + rows = build_task_rows([(make_issue(1), date(2026, 10, 27))], {}, today=TODAY) + assert rows[0]["overdue"] is True + + +def test_not_overdue_today(): + """Срок сегодня -- НЕ просрочен (строгое сравнение).""" + rows = build_task_rows([(make_issue(1), date(2026, 10, 28))], {}, today=TODAY) + assert rows[0]["overdue"] is False + + +def test_not_overdue_tomorrow(): + rows = build_task_rows([(make_issue(1), date(2026, 10, 29))], {}, today=TODAY) + assert rows[0]["overdue"] is False + + +def test_not_overdue_without_due(): + rows = build_task_rows([(make_issue(1), None)], {}, today=TODAY) + assert rows[0]["overdue"] is False + + +# -- Поля строки -- + + +def test_row_fields(): + issue = make_issue( + 42, + priority="P1", + status="New", + fixed_version="v2.5.0", + subject="Починить баг", + ) + rows = build_task_rows([(issue, date(2026, 10, 27))], {}, today=TODAY) + row = rows[0] + assert row["issue_id"] == 42 + assert row["subject"] == "Починить баг" + assert row["priority"] == "P1" + assert row["version_name"] == "v2.5.0" + assert row["due_text"] == "2026-10-27" + assert row["overdue"] is True + + +def test_row_empty_version_and_due_text(): + """Без версии и без срока в строке пустые строки, а не заглушки.""" + rows = build_task_rows([(make_issue(7), None)], {}, today=TODAY) + assert rows[0]["version_name"] == "" + assert rows[0]["due_text"] == "" + + +# -- Перевод статусов -- + + +def test_status_translated_by_dict(): + issue = make_issue(1, status="New") + rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY) + assert rows[0]["status_ru"] == "Новая" + + +def test_status_unknown_passthrough(): + issue = make_issue(1, status="SomeNewStatus") + rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY) + assert rows[0]["status_ru"] == "SomeNewStatus" + + +# -- Саммари -- + + +def test_summary_mixed_items(): + """Смесь: без версии / версия без срока / просроченная / с оценками и без.""" + items = [ + # без версии, без срока, без оценки + (make_issue(1, spent_hours=1.5), None), + # версия есть, срока нет, оценка есть + (make_issue(2, fixed_version="v1", spent_hours=2.0, estimated_hours=4.0), None), + # просроченная, оценка есть + ( + make_issue(3, fixed_version="v2", spent_hours=0.5, estimated_hours=3.0), + date(2026, 10, 27), + ), + # не просроченная, оценка None + ( + make_issue(4, fixed_version="v3", spent_hours=0.0), + date(2026, 10, 29), + ), + ] + summary = calculate_task_summary(items, today=TODAY) + + assert summary["total"] == 4 + assert summary["overdue"] == 1 + assert summary["no_version"] == 1 + assert summary["no_due"] == 1 + assert summary["spent_hours"] == 4.0 + assert summary["estimated_hours"] == 7.0 + assert summary["estimated_count"] == 2 + + +def test_summary_empty(): + summary = calculate_task_summary([], today=TODAY) + assert summary == { + "total": 0, + "overdue": 0, + "no_version": 0, + "no_due": 0, + "spent_hours": 0.0, + "estimated_hours": 0.0, + "estimated_count": 0, + } + + +def test_summary_missing_attributes_default_to_zero(): + """У issue без атрибутов spent_hours/estimated_hours безопасные нули.""" + issue = mock.MagicMock(spec=["id", "subject", "status", "priority"]) + issue.id = 5 + issue.status = "New" + issue.priority.name = "P3" + + summary = calculate_task_summary([(issue, None)], today=TODAY) + + assert summary["spent_hours"] == 0.0 + assert summary["estimated_hours"] == 0.0 + assert summary["estimated_count"] == 0 + assert summary["no_version"] == 1 + assert summary["no_due"] == 0 + + +# -- Строка саммари -- + + +def test_format_summary_line_with_estimates(): + summary = { + "total": 4, + "overdue": 1, + "no_version": 1, + "no_due": 1, + "spent_hours": 4.0, + "estimated_hours": 7.0, + "estimated_count": 2, + } + line = format_summary_line(summary) + assert line == ( + "Открытых: 4 | просроченных: 1 | без версии: 1 | без срока: 1 " + "| потрачено: 4h | оценка: 7h (по 2 из 4)" + ) + + +def test_format_summary_line_without_estimates(): + summary = { + "total": 3, + "overdue": 0, + "no_version": 2, + "no_due": 1, + "spent_hours": 2.5, + "estimated_hours": 0.0, + "estimated_count": 0, + } + line = format_summary_line(summary) + assert "оценка" not in line + assert line == ( + "Открытых: 3 | просроченных: 0 | без версии: 2 | без срока: 1 | потрачено: 2.5h" + ) + + +def test_format_summary_line_fractional_hours_use_g_format(): + """Часы форматируются :g -- без хвостовых нулей.""" + summary = { + "total": 1, + "overdue": 0, + "no_version": 0, + "no_due": 0, + "spent_hours": 21.5, + "estimated_hours": 7.25, + "estimated_count": 1, + } + line = format_summary_line(summary) + assert "21.5h" in line + assert "7.25h" in line + assert "21.500000" not in line diff --git a/tests/test_task_table.py b/tests/test_task_table.py new file mode 100644 index 0000000..ba8e917 --- /dev/null +++ b/tests/test_task_table.py @@ -0,0 +1,160 @@ +"""Тесты консольного форматтера таблицы режима --my-tasks (#71).""" + +import re +from typing import cast + +import pytest +from redmine_reporter.formatters.task_table import TaskTableFormatter +from redmine_reporter.types import TaskRow + +BASE_URL = "https://red.eltex.loc" + +# OSC 8-гиперссылка: esc-последовательность до текста, текст, закрытие ссылки. +OSC8_RE = re.compile(r"\x1b\]8;;[^\x1b]*\x1b\\") + + +def make_row(**overrides) -> TaskRow: + row: TaskRow = { + "issue_id": 42, + "subject": "Починить баг", + "status_ru": "Новая", + "priority": "P2", + "version_name": "v2.5.0", + "due_text": "2026-10-15", + "overdue": False, + } + # **-распаковка dict[str, Any] в TypedDict-литерале mypy не поддерживает, + # а kwargs-словарь типизируется только как dict[str, Any], поэтому приведение. + row.update(cast(TaskRow, overrides)) + return row + + +def expected_hyperlink(issue_id, base_url=BASE_URL): + return f"\x1b]8;;{base_url}/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\" + + +# -- OSC 8-гиперссылка на ID -- + + +def test_id_wrapped_in_exact_osc8_sequence(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row()]) + assert expected_hyperlink(42) in output + + +def test_base_url_trailing_slash_not_duplicated(): + """base_url с завершающим слэшем не даёт двойного слэша перед /issues.""" + output = TaskTableFormatter(base_url=BASE_URL + "/").format([make_row()]) + assert expected_hyperlink(42) in output + assert "//issues/" not in output + + +def test_hyperlink_text_is_issue_number_only(): + """Текст ссылки -- только номер, тема не попадает внутрь OSC 8.""" + output = TaskTableFormatter(base_url=BASE_URL).format( + [make_row(issue_id=7, subject="Тема задачи")] + ) + assert expected_hyperlink(7) in output + assert "Тема задачи" in output + start = output.index("Тема задачи") + assert "\x1b" not in output[:start].replace(expected_hyperlink(7), "") + + +def test_hyperlink_uses_row_issue_id(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row(issue_id=1234)]) + assert expected_hyperlink(1234) in output + + +# -- Заполнение пустых полей -- + + +def test_empty_version_rendered_as_dash(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row(version_name="")]) + assert "-" in output + + +def test_empty_due_rendered_as_dash(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row(due_text="")]) + assert "-" in output + + +def test_nonempty_fields_not_replaced_by_dash(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row()]) + assert "v2.5.0" in output + assert "2026-10-15" in output + + +# -- Маркер просрочки -- + + +def test_overdue_due_appends_marker(): + output = TaskTableFormatter(base_url=BASE_URL).format( + [make_row(due_text="2026-10-15", overdue=True)] + ) + assert "2026-10-15 (!)" in output + + +def test_not_overdue_due_without_marker(): + output = TaskTableFormatter(base_url=BASE_URL).format( + [make_row(due_text="2026-10-15", overdue=False)] + ) + assert "2026-10-15" in output + assert "(!)" not in output + + +# -- Заголовки и содержимое -- + + +def test_column_headers_present(): + output = TaskTableFormatter(base_url=BASE_URL).format([make_row()]) + for header in ("ID", "Тема", "Статус", "Приор.", "Версия", "Срок"): + assert header in output + + +def test_status_passed_through_as_is(): + """status_ru из TaskRow попадает в таблицу без изменений.""" + output = TaskTableFormatter(base_url=BASE_URL).format( + [make_row(status_ru="В работе")] + ) + assert "В работе" in output + + +def test_subject_and_priority_present(): + output = TaskTableFormatter(base_url=BASE_URL).format( + [make_row(subject="Обновить драйвер", priority="P1")] + ) + assert "Обновить драйвер" in output + assert "P1" in output + + +def test_multiple_rows_rendered(): + rows = [make_row(issue_id=1), make_row(issue_id=2)] + output = TaskTableFormatter(base_url=BASE_URL).format(rows) + assert expected_hyperlink(1) in output + assert expected_hyperlink(2) in output + + +def test_empty_rows_renders_headers_only(): + output = TaskTableFormatter(base_url=BASE_URL).format([]) + assert "ID" in output + assert expected_hyperlink(42) not in output + + +# -- ESC только внутри ссылок -- + + +def test_esc_only_inside_hyperlinks(): + """После вырезания OSC 8-ссылок в строке не остаётся ESC-символов.""" + rows = [ + make_row(issue_id=1), + make_row(issue_id=2, version_name=""), + make_row(issue_id=3, due_text="", overdue=True), + ] + output = TaskTableFormatter(base_url=BASE_URL).format(rows) + cleaned = OSC8_RE.sub("", output) + assert "\x1b" not in cleaned + + +def test_save_raises_not_implemented(): + formatter = TaskTableFormatter(base_url=BASE_URL) + with pytest.raises(NotImplementedError): + formatter.save([], "unused.xlsx") diff --git a/tests/test_utils.py b/tests/test_utils.py index 007f149..a0e18fd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -110,3 +110,21 @@ def test_get_version_falls_back_to_str_when_no_name(): fixed_version = MockVersionNoName() assert get_version(MockIssue()) == "fallback-id" + + +def test_get_version_custom_default(): + """Явный default заменяет , когда версия не назначена.""" + + class MockIssue: + pass + + assert get_version(MockIssue(), default="") == "" + + +def test_get_version_custom_default_not_used_when_version_present(): + """При наличии версии default не применяется.""" + + class MockIssue: + fixed_version = "v1.0" + + assert get_version(MockIssue(), default="") == "v1.0" diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index e26299b..b19930f 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -5,7 +5,6 @@ from pathlib import Path from unittest import mock import yaml - from redmine_reporter.yaml_config import ( check_file_permissions, ensure_config_dir, From f00c6705bab49f68dab256d7a857514bcf7e0d05 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: Wed, 28 Oct 2026 15:53:50 +0700 Subject: [PATCH 2/3] feat: filter Wishlist from --my-tasks; document the mode (#71) Tasks in the "Wishlist" bucket version are now excluded from the list and summary entirely (no due date, noise backlog); exact case match, tasks without a version are unaffected. Document --my-tasks in README and the user guide (sorting, summary metrics, OSC 8 links, stream separation); all examples use synthetic data. Declare ruff known-first-party in pyproject so the lint result is CWD-independent. --- README.md | 4 +++ docs/USER_GUIDE.md | 68 +++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ redmine_reporter/cli.py | 11 +++++- redmine_reporter/task_list.py | 25 +++++++++++++ tests/test_cli.py | 40 +++++++++++++++++++++ tests/test_client.py | 1 + tests/test_config.py | 1 + tests/test_formatters.py | 1 + tests/test_mailer.py | 1 + tests/test_task_list.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_task_table.py | 1 + tests/test_yaml_config.py | 1 + 13 files changed, 224 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bfa40eb..d44a471 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ CLI-инструмент для генерации отчётов по зада - Гибкий выбор периода: `--date`, env `FROM_DATE`, YAML `period.from`, по умолчанию — текущий месяц. - `--commit`: сохранение отчёта в файл и авто-сдвиг начала периода в конфиге для следующего запуска. - `--send`: отправка отчёта по email через SMTP (plain-text или HTML-письмо) с авто-сдвигом начала периода при успешной отправке. +- `--my-tasks`: список назначенных вам открытых задач -- таблица с сортировкой по срочности (приоритет, срок версии) и сводкой; см. [руководство](docs/USER_GUIDE.md#список-открытых-задач). - YAML-конфиг с секретами через `${VAR}`; приоритет: CLI-флаги > env > .env > YAML > дефолты (нюанс с `--config` — см. docs/CONFIG.md). ## Установка @@ -57,6 +58,9 @@ redmine-reporter # Сохранить в файл и сдвинуть начало периода для следующего запуска redmine-reporter --commit + +# Список ваших открытых задач (сортировка по срочности) +redmine-reporter --my-tasks ``` ## Документация diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 55bad56..fccb924 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -6,6 +6,7 @@ - [Первоначальная настройка](#первоначальная-настройка) - [Выбор периода](#выбор-периода) - [Отчёт за другого пользователя](#отчёт-за-другого-пользователя) +- [Список открытых задач](#список-открытых-задач) - [Экспорт в файл](#экспорт-в-файл) - [Содержимое отчёта](#содержимое-отчёта) - [Отправка по email](#отправка-по-email) @@ -115,6 +116,70 @@ redmine-reporter --user-name "Иванов Иван Иванович" - Несколько совпадений по имени → ошибка «Multiple users match...» со списком логинов (при дублях логина — ID). - Ноль совпадений → ошибка «User '...' not found...». +## Список открытых задач + +`--my-tasks` -- отдельный режим: вместо отчёта по затраченному времени выводит +открытые задачи, назначенные на вас, по всем проектам. Отчётное окно периода +к режиму не применяется. + +```bash +redmine-reporter --my-tasks # задачи владельца API-ключа +redmine-reporter --my-tasks --user-id 42 # чужой список по ID +redmine-reporter --my-tasks --user-login ivanov # по логину +``` + +Правила: + +- `--user-id` / `--user-login` / `--user-name` выбирают, чей список показать; по умолчанию -- владелец API-ключа. Разрешение пользователей то же, что для отчёта (см. [Отчёт за другого пользователя](#отчёт-за-другого-пользователя)). +- `--url`, `--api-key`, `--config`, `--config-path`, `--verbose`, `--debug` работают как обычно. +- Совмещение с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) -- ошибка «--my-tasks cannot be used with report-generating flags.», exit 1. +- Задачи в версии `Wishlist` исключаются из режима целиком: не попадают ни в таблицу, ни в сводку. + +Сортировка -- от срочного к менее срочному: + +1. Приоритет `P1..P5`: меньший номер срочнее; неизвестное имя приоритета получает низший ранг. +2. Внутри приоритетной группы -- срок версии (due date) по возрастанию. +3. Задачи без версии или без срока -- в конец своей приоритетной группы. +4. При прочих равных -- по возрастанию ID. + +Таблица печатается в stdout, сводка -- в stderr перед таблицей. При +перенаправлении (`redmine-reporter --my-tasks > tasks.txt`) в файл попадает +только таблица. + +Колонки таблицы: ID | Тема | Статус | Приор. | Версия | Срок. Пример +(синтетические данные, ID в терминале кликабелен): + +``` + ID Тема Статус Приор. Версия Срок +---- -------------------------------- -------------- -------- -------- -------------- + 101 Критический дефект сборки образа В работе P1 v2.4.0 2026-10-15 (!) + 102 Обновить документацию API Новая P2 v2.5.0 2026-12-01 + 103 Ревью pull request Обратная связь P3 - - +``` + +- ID -- гиперссылка (OSC 8) на страницу задачи `/issues/`: в терминалах с поддержкой кликабельна, без поддержки остаётся обычным номером. +- Срок -- дата релиза целевой версии; просроченный (раньше сегодняшней даты) помечается `(!)`. +- Пустые «Версия» и «Срок» -- прочерк `-`. +- Статусы переводятся на русский тем же словарём, что и в отчётах: встроенный словарь + `report.status_translation` (см. [Перевод статусов](#перевод-статусов)). + +Сводка (пример с синтетическими данными): + +``` +Открытых: 3 | просроченных: 1 | без версии: 1 | без срока: 0 | потрачено: 12.5h | оценка: 8h (по 2 из 3) +``` + +- «просроченных» -- задачи со сроком версии раньше сегодняшней даты. +- «без версии» -- задачи без целевой версии; «без срока» -- задачи с версией, но без известной даты релиза. +- «потрачено» -- сумма полей `spent_hours` задач списка. +- «оценка» -- сумма полей `estimated_hours`; «(по N из M)» -- у скольких задач из списка есть оценка. Если оценок нет ни у одной задачи, блок «оценка» не выводится. + +Сроки версий дозагружаются отдельным запросом на каждую уникальную версию +списка. Недоступная версия -- предупреждение в stderr и пустой срок у её +задач, вывод не прерывается. + +Пустой список (в том числе когда все открытые задачи попали в исключаемую +версию `Wishlist`): «ℹ️ Открытых задач нет.» (stderr), exit 0. + ## Экспорт в файл Флаг `--output` определяется по четырём правилам: @@ -284,6 +349,7 @@ redmine-reporter --send --commit | `--config-path` | Путь к YAML-конфигу: загрузка, запись `--init-config` и `--commit`. По умолчанию `~/.config/redmine-reporter/config.yml`. | | `--commit` | Сохранить отчёт в файл и сдвинуть начало периода (`period.from` = конец окна) в YAML. | | `--send` | Отправить отчёт по email после сохранения файла; при подтверждении и успехе сдвинуть начало периода. | +| `--my-tasks` | Вместо отчёта вывести открытые задачи, назначенные на вас (или на пользователя из `--user-id`/`--user-login`/`--user-name`): таблица с сортировкой по срочности + сводка в stderr. Несовместим с `--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`. Подробнее: [Список открытых задач](#список-открытых-задач). | ## Устранение неполадок @@ -294,6 +360,7 @@ redmine-reporter --send --commit | «User '...' not found...» | Пользователь не найден ни по ID, ни по логину, ни по имени | Проверьте написание; логин сравнивается точно с учётом регистра. | | «Multiple users match...» | По имени найдено несколько пользователей (или несколько дублей логина) | Используйте `--user-login` с логином из списка или уточните числовой ID для `--user-id`. | | Указано несколько `--user-*` | Флаги взаимоисключающие | Оставьте только один из `--user-id` / `--user-login` / `--user-name`. | +| «--my-tasks cannot be used with report-generating flags.» (exit 1) | `--my-tasks` совмещён с отчётными флагами (`--date`, `--output`, `--compact`, `--summary`, `--no-time`, `--by-activity`, `--commit`, `--send`) | Запускайте режим отдельно: список задач и отчёт -- разные команды (см. [Список открытых задач](#список-открытых-задач)). | | «Неизвестный формат файла: '.xyz'. Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx» (exit 1) | `--output` с неизвестным расширением | Используйте одно из поддерживаемых расширений. | | Ошибка про `odfpy` при `.odt` | Пакет `odfpy` не установлен | `pip install odfpy` или выберите другой формат. | | «Email не настроен. Добавьте секцию 'email' в конфиг...» (exit 1) | `--send` без секции `email` в YAML или с пустым `smtp.host` | Добавьте секцию `email` (см. [CONFIG.md](CONFIG.md)). | @@ -303,6 +370,7 @@ redmine-reporter --send --commit | «Ошибка отправки письма: ...» | Прочая ошибка SMTP | Смотрите детали после двоеточия; файл отчёта уже сохранён на диске. | | Предупреждение о правах конфига при старте | Права `config.yml` шире `0600` | `chmod 600 ~/.config/redmine-reporter/config.yml`. | | Предупреждение о MITM при старте | `verify_ssl: false` / `REDMINE_VERIFY=false` — проверка TLS отключена | Включите проверку или используйте только в доверенной сети. | +| «⚠️ Could not load version ...: Due date will be empty for its issues.» в режиме `--my-tasks` | Версия из списка задач недоступна (нет прав, удалена, сбой сети) | Некритично: срок этой версии останется пустым; проверьте права доступа к проекту версии. | | Не виден traceback при ошибке | Traceback показывается только с `--verbose`/`--debug` | Повторите запуск с `--debug`. | API-ключ в текстах ошибок маскируется (`***`). diff --git a/pyproject.toml b/pyproject.toml index 78f6d5b..9f57bab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,3 +62,6 @@ ignore_missing_imports = true [tool.isort] profile = "black" + +[tool.ruff.lint.isort] +known-first-party = ["redmine_reporter"] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 81001e7..ca2d82e 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -16,7 +16,12 @@ from .formatters.factory import get_console_formatter, get_formatter_by_extensio from .formatters.task_table import TaskTableFormatter from .mailer import send_report from .report_builder import build_grouped_report, calculate_summary -from .task_list import build_task_rows, calculate_task_summary, format_summary_line +from .task_list import ( + build_task_rows, + calculate_task_summary, + filter_excluded_versions, + format_summary_line, +) from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config # Маскируем API-ключ Redmine в тексте ошибок (#55): @@ -287,6 +292,10 @@ def _run_my_tasks(args: argparse.Namespace) -> int: logging.exception("Unexpected error") # noqa: LOG015 return 1 + # Бакет-версии (Wishlist) исключаются до проверки на пустоту: если все + # открытые задачи в них, пользователь видит "Открытых задач нет". + items = filter_excluded_versions(items) + if not items: print("ℹ️ Открытых задач нет.", file=sys.stderr) return 0 diff --git a/redmine_reporter/task_list.py b/redmine_reporter/task_list.py index f5055e9..5cebebd 100644 --- a/redmine_reporter/task_list.py +++ b/redmine_reporter/task_list.py @@ -22,6 +22,31 @@ def _issue_version_name(issue: Issue) -> str: return get_version(issue, default="") +# "Wishlist" -- постоянная бакет-версия без срока (личный бакет Wendy, факт +# из трекера): задачи в ней -- отложенные идеи, создающие мусорный фон в +# персональной выборке, поэтому режим --my-tasks исключает их целиком -- +# из таблицы и из саммари. Сравнение имени точное, без нормализации регистра. +EXCLUDED_VERSION_NAMES: frozenset[str] = frozenset({"Wishlist"}) + + +def filter_excluded_versions( + items: list[tuple[Issue, date | None]], +) -> list[tuple[Issue, date | None]]: + """Убирает задачи версий-исключений из выборки --my-tasks. + + Доступ к fixed_version.name getattr-безопасный: задача без версии или + с версией без имени остаётся в выборке. Даты в парах сохраняются как есть. + """ + kept: list[tuple[Issue, date | None]] = [] + for item in items: + version = getattr(item[0], "fixed_version", None) + name = getattr(version, "name", None) if version is not None else None + if name and str(name) in EXCLUDED_VERSION_NAMES: + continue + kept.append(item) + return kept + + def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]: """Ключ сортировки: приоритет, затем срок по возрастанию, затем id. diff --git a/tests/test_cli.py b/tests/test_cli.py index ff67bc7..ab6c233 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from datetime import date from unittest import mock import pytest + from redmine_reporter.cli import main, parse_date_range from redmine_reporter.client import RedmineAPIError @@ -2115,6 +2116,45 @@ class TestMyTasksFlag: assert "потрачено:" in captured.err assert "Открытых:" not in captured.out + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_wishlist_filtered_out_of_table_and_summary( + self, mock_fetch, capsys + ): + """Задача из Wishlist не попадает ни в таблицу, ни в саммари.""" + wishlist = _make_task_issue(50, subject="Идея на потом") + wishlist.fixed_version = mock.MagicMock() + wishlist.fixed_version.name = "Wishlist" + normal = _make_task_issue(51, subject="Рабочая задача") + mock_fetch.return_value = [(wishlist, None), (normal, None)] + + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + + assert _expected_link(51) in captured.out + assert "Рабочая задача" in captured.out + assert _expected_link(50) not in captured.out + assert "Идея на потом" not in captured.out + assert "Открытых: 1" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_open_issues") + def test_my_tasks_only_wishlist_reports_empty_and_exits_zero( + self, mock_fetch, capsys + ): + """Все задачи в Wishlist -- выборка пуста: 'Открытых задач нет', код 0.""" + wishlist = _make_task_issue(60, subject="Ещё идея") + wishlist.fixed_version = mock.MagicMock() + wishlist.fixed_version.name = "Wishlist" + mock_fetch.return_value = [(wishlist, None)] + + code = main(["--my-tasks"]) + captured = capsys.readouterr() + assert code == 0 + assert "Открытых задач нет" in captured.err + assert captured.out == "" + @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_open_issues") def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys): diff --git a/tests/test_client.py b/tests/test_client.py index e8f005b..65256e9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ from datetime import date from unittest import mock import pytest + from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues from redmine_reporter.config import DEFAULT_REDMINE_VERIFY diff --git a/tests/test_config.py b/tests/test_config.py index a6d0afe..824ea98 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ from typing import ClassVar from unittest import mock import pytest + from redmine_reporter.config import AppConfig, Config, EmailConfig diff --git a/tests/test_formatters.py b/tests/test_formatters.py index b5d42a0..091276a 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -4,6 +4,7 @@ from unittest import mock import pytest from odf.opendocument import OpenDocument, OpenDocumentText + from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.factory import get_formatter_by_extension diff --git a/tests/test_mailer.py b/tests/test_mailer.py index ec19ebc..8a6a897 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -2,6 +2,7 @@ import smtplib from unittest import mock import pytest + from redmine_reporter.client import RedmineAPIError from redmine_reporter.config import EmailConfig, SmtpConfig from redmine_reporter.mailer import build_message, send_report diff --git a/tests/test_task_list.py b/tests/test_task_list.py index e06aad5..257c574 100644 --- a/tests/test_task_list.py +++ b/tests/test_task_list.py @@ -4,9 +4,11 @@ from datetime import date from unittest import mock from redmine_reporter.task_list import ( + EXCLUDED_VERSION_NAMES, PRIORITY_RANK, build_task_rows, calculate_task_summary, + filter_excluded_versions, format_summary_line, priority_rank, ) @@ -100,6 +102,72 @@ def test_rows_without_due_go_last_and_tiebreak_by_id(): assert [r["issue_id"] for r in rows] == [30, 12, 25] +# -- Фильтр версий-исключений (Wishlist) -- + + +def test_excluded_version_names_constant(): + """Точный состав бакет-версий, исключаемых из --my-tasks.""" + assert EXCLUDED_VERSION_NAMES == frozenset({"Wishlist"}) + + +def test_filter_excludes_wishlist_issue(): + """Задача в версии Wishlist удаляется из выборки.""" + items = [ + (make_issue(1, fixed_version="Wishlist"), None), + (make_issue(2, fixed_version="v2.5.0"), date(2026, 11, 1)), + ] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [2] + + +def test_filter_keeps_issue_without_version(): + """Задача без версии фильтром не трогается.""" + items = [(make_issue(3), None)] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [3] + + +def test_filter_keeps_regular_version_without_due(): + """Обычная версия без срока -- не бакет, задача остаётся.""" + items = [(make_issue(4, fixed_version="v3.0.0"), None)] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [4] + + +def test_filter_is_case_sensitive(): + """Сравнение имени версии точное: wishlist строчными -- НЕ исключение.""" + items = [ + (make_issue(5, fixed_version="wishlist"), None), + (make_issue(6, fixed_version="WISHLIST"), None), + (make_issue(7, fixed_version="Wishlist"), None), + ] + kept = filter_excluded_versions(items) + assert [issue.id for issue, _ in kept] == [5, 6] + + +def test_filter_returns_only_excluded_when_all_match(): + items = [(make_issue(8, fixed_version="Wishlist"), None)] + assert filter_excluded_versions(items) == [] + + +def test_filter_preserves_due_pairing(): + """Фильтр не теряет парную к задаче дату срока.""" + due = date(2026, 12, 1) + items = [(make_issue(9, fixed_version="v1"), due)] + ((issue, kept_due),) = filter_excluded_versions(items) + assert issue.id == 9 + assert kept_due is due + + +def test_filter_tolerates_version_without_name_attribute(): + """fixed_version без атрибута name -- задача остаётся (getattr-безопасность).""" + issue = make_issue(10) + issue.fixed_version = mock.MagicMock(spec=[]) + items = [(issue, None)] + kept = filter_excluded_versions(items) + assert [i.id for i, _ in kept] == [10] + + # -- Просроченность -- diff --git a/tests/test_task_table.py b/tests/test_task_table.py index ba8e917..fa34327 100644 --- a/tests/test_task_table.py +++ b/tests/test_task_table.py @@ -4,6 +4,7 @@ import re from typing import cast import pytest + from redmine_reporter.formatters.task_table import TaskTableFormatter from redmine_reporter.types import TaskRow diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index b19930f..e26299b 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest import mock import yaml + from redmine_reporter.yaml_config import ( check_file_permissions, ensure_config_dir, From 375ea13a1fe96594b3500281051965a0f02b64a7 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: Wed, 28 Oct 2026 15:55:53 +0700 Subject: [PATCH 3/3] chore: bump version to 2.1.0 --- pyproject.toml | 2 +- redmine_reporter/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f57bab..482a678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "2.0.0" +version = "2.1.0" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index 8c0d5d5..9aa3f90 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.1.0"