commit e412bb7446871dcf77985fa362283c8bc5021c3c Author: Кокос Артем Николаевич Date: Tue Jan 20 09:32:03 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba756e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environments +.venv +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs & Editors +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# Logs and databases +*.log +*.sqlite3 +*.db + +# Environment variables +.env +.env.local +.env.*.local + +# Coverage +htmlcov/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover + +# Pytest +.pytest_cache/ +test-results.xml + +# MyPy +.mypy_cache/ + +# Black, Ruff, etc. +.ruff_cache/ +.ipynb_checkpoints/ + +# Build artifacts +*.tar.gz +*.whl + +# Local config overrides +config.local.yaml +secrets.json + +# Temporary files +*.tmp +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..dad94de --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# redmine-reporter + +Internal tool to generate Redmine issue reports based on your time entries. + +## Features + +- Secure credential handling via environment variables +- Compact or fancy table output +- Grouping by project and version +- Status translation to Russian +- CLI with intuitive arguments + +## Installation + +```bash +git clone https://your-gitea/redmine-reporter.git +cd redmine-reporter +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Configuration + +Create `.env` (not committed!): + +```ini +REDMINE_URL=https://red.eltex.loc/ +REDMINE_USER=artem.kokos +REDMINE_PASSWORD=your_password_here +``` + +Or export in shell: + +```bash +export REDMINE_URL=https://red.eltex.loc/ +export REDMINE_USER=artem.kokos +export REDMINE_PASSWORD=... +``` + +## Usage + +```bash +# Default date range +redmine-reporter + +# Custom date range +redmine-reporter --date 2025-12-01--2026-01-31 + +# Compact mode +redmine-reporter --compact +``` + +## Development + +```bash +pip install -e ".[dev]" +pytest +black . +isort . +``` + +> 🔒 Never commit `.env` or credentials! diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fded2b4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "redmine-reporter" +version = "1.0.0" +description = "Redmine time-entry based issue reporter for internal use" +readme = "README.md" +authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] +license = { text = "Proprietary" } +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: POSIX :: Linux", + "Environment :: Console", +] +requires-python = ">=3.8" +dependencies = [ + "python-redmine>=2.4.0", + "tabulate>=0.9.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "black>=23.0", + "isort>=5.12", + "mypy>=1.0", + "ruff>=0.1.0", +] + +[project.scripts] +redmine-reporter = "redmine_reporter.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["redmine_reporter*"] + +[tool.black] +line-length = 100 +target-version = ['py38'] + +[tool.isort] +profile = "black" +multi_line_output = 3 diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/redmine_reporter/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py new file mode 100644 index 0000000..0f749ea --- /dev/null +++ b/redmine_reporter/cli.py @@ -0,0 +1,74 @@ +import sys +import argparse +from typing import List, Optional +from redminelib.resources import Issue +from .config import Config +from .client import fetch_issues_by_time_entries +from .formatter import format_compact, format_table + + +def parse_date_range(date_arg: str) -> tuple[str, str]: + if "--" not in date_arg: + raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") + parts = date_arg.split("--", 1) + if len(parts) != 2: + raise ValueError("Invalid date range format") + return parts[0].strip(), parts[1].strip() + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + prog="redmine-reporter", + description="Generate Redmine issue report based on your time entries." + ) + parser.add_argument( + "--date", + default=Config.DEFAULT_DATE_RANGE, + help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)" + ) + parser.add_argument( + "--compact", + action="store_true", + help="Use compact plain-text output instead of table" + ) + args = parser.parse_args(argv) + + try: + Config.validate() + except ValueError as e: + print(f"❌ Configuration error: {e}", file=sys.stderr) + return 1 + + try: + from_date, to_date = parse_date_range(args.date) + except ValueError as e: + print(f"❌ Date error: {e}", file=sys.stderr) + return 1 + + try: + issues = fetch_issues_by_time_entries(from_date, to_date) + except Exception as e: + print(f"❌ Redmine API error: {e}", file=sys.stderr) + return 1 + + if issues is None: + print("ℹ️ No time entries found in the given period.", file=sys.stderr) + return 0 + + print(f"✅ Total issues: {len(issues)} [{args.date}]") + + try: + if args.compact: + output = format_compact(issues) + else: + output = format_table(issues) + print(output) + except Exception as e: + print(f"❌ Formatting error: {e}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py new file mode 100644 index 0000000..fc53718 --- /dev/null +++ b/redmine_reporter/client.py @@ -0,0 +1,41 @@ +from typing import List, Optional, Set +from redminelib import Redmine +from redminelib.resources import Issue +from .config import Config + + +def fetch_issues_by_time_entries(from_date: str, to_date: str) -> Optional[List[Issue]]: + """ + Fetch unique issues linked to time entries of the current user in given date range. + """ + + redmine = Redmine( + Config.REDMINE_URL, + username=Config.REDMINE_USER, + password=Config.REDMINE_PASSWORD + ) + + current_user = redmine.user.get('current') + time_entries = redmine.time_entry.filter( + user_id=current_user.id, + from_date=from_date, + to_date=to_date + ) + + issue_ids: Set[int] = set() + for entry in time_entries: + if hasattr(entry, 'issue') and entry.issue: + issue_ids.add(entry.issue.id) + + if not issue_ids: + return None + + # Fetch full issue objects with project/version/status + issue_list_str = ','.join(str(i) for i in issue_ids) + issues = redmine.issue.filter( + issue_id=issue_list_str, + status_id='*', + sort='project:asc' + ) + + return list(issues) diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py new file mode 100644 index 0000000..9e2f9b3 --- /dev/null +++ b/redmine_reporter/config.py @@ -0,0 +1,21 @@ +import os +from dotenv import load_dotenv + + +load_dotenv() + + +class Config: + REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/") + REDMINE_USER = os.getenv("REDMINE_USER") + REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") + DEFAULT_DATE_RANGE = "2025-12-19--2026-01-31" + + @classmethod + def validate(cls) -> None: + if not cls.REDMINE_URL: + raise ValueError("REDMINE_URL is required (set via env or .env)") + if not cls.REDMINE_USER: + raise ValueError("REDMINE_USER is required") + if not cls.REDMINE_PASSWORD: + raise ValueError("REDMINE_PASSWORD is required") diff --git a/redmine_reporter/formatter.py b/redmine_reporter/formatter.py new file mode 100644 index 0000000..afdf8cf --- /dev/null +++ b/redmine_reporter/formatter.py @@ -0,0 +1,70 @@ +from typing import List +from redminelib.resources import Issue + + +STATUS_TRANSLATION = { + 'Closed': 'Закрыто', + 'Re-opened': 'В работе', + 'New': 'В работе', + 'Resolved': 'Решена', + 'Pending': 'Ожидание', + 'Feedback': 'В работе', + 'In Progress': 'В работе', + 'Rejected': 'Закрыто', + 'Confirming': 'Ожидание', +} + + +def get_version(issue: Issue) -> str: + return str(getattr(issue, 'fixed_version', '')) + + +def format_compact(issues: List[Issue]) -> str: + lines = [] + prev_project = None + prev_version = None + + for issue in issues: + project = str(issue.project) + version = get_version(issue) + status = str(issue.status) + + display_project = project if project != prev_project else "" + display_version = version if (project != prev_project or version != prev_version) else "" + + lines.append(f"{display_project} | {display_version} | {issue.id}. {issue.subject} | {status}") + + prev_project = project + prev_version = version + + return "\n".join(lines) + + +def format_table(issues: List[Issue]) -> str: + from tabulate import tabulate + + rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] + prev_project = None + prev_version = None + + for issue in issues: + project = str(issue.project) + version = get_version(issue) + status_en = str(issue.status) + status_ru = STATUS_TRANSLATION.get(status_en, status_en) + + display_project = project if project != prev_project else "" + display_version = version if (project != prev_project or version != prev_version) else "" + + rows.append([ + display_project, + display_version, + f"{issue.id}. {issue.subject}", + status_ru, + "" # placeholder for spent time (future extension) + ]) + + prev_project = project + prev_version = version + + return tabulate(rows, headers="firstrow", tablefmt="fancy_grid") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4d8319e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,20 @@ +import pytest +from redmine_reporter.cli import parse_date_range + + +def test_parse_date_range_valid(): + assert parse_date_range("2025-01-01--2025-12-31") == ("2025-01-01", "2025-12-31") + + +def test_parse_date_range_with_spaces(): + assert parse_date_range("2025-01-01 -- 2025-12-31") == ("2025-01-01", "2025-12-31") + + +def test_parse_date_range_invalid_no_separator(): + with pytest.raises(ValueError, match="must be in format"): + parse_date_range("2025-01-01") + + +def test_parse_date_range_invalid_parts(): + with pytest.raises(ValueError, match="Invalid date range format"): + parse_date_range("2025-01-01--")