feat: --commit flag (#44)
- --commit: после генерации отчёта сохраняет период в period.last_used.from/to YAML-конфига - При period.dynamic=true следующий запуск вычисляет период от last_used: полный месяц → следующий месяц, произвольный диапазон → та же длина - При period.dynamic=false перезаписывает default_from/default_to - При period.precision=datetime сохраняет timestamps с точностью до секунд - --commit без --output сохраняет отчёт по шаблону из конфига - compute_next_period() в config.py - save_period_to_config() в yaml_config.py - 23 новых теста (7 CLI, 6 config, 6 yaml, 4 date_range) Closes #44
This commit is contained in:
@@ -14,7 +14,7 @@ from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||
from .config import Config
|
||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||
from .report_builder import build_grouped_report, calculate_summary
|
||||
from .yaml_config import ensure_config_dir, resolve_output_path
|
||||
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||
|
||||
|
||||
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||
@@ -195,6 +195,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"),
|
||||
help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Save used period as last_used in YAML config and auto-commit to file",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# --init-config: обработка до всего остального
|
||||
@@ -365,6 +370,42 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.commit:
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=default_format,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=args.no_time,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
formatter.save(rows, output_arg)
|
||||
print(f"✅ Report saved to {output_arg}")
|
||||
except Exception as e:
|
||||
print(f"❌ Export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
else:
|
||||
if args.compact:
|
||||
formatter = get_console_formatter("compact")
|
||||
@@ -382,6 +423,26 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
print(f"❌ Formatting error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.commit:
|
||||
precision = Config.get_period_precision()
|
||||
dynamic = Config._app.period_dynamic if Config._app else False
|
||||
|
||||
if precision == "datetime":
|
||||
from datetime import datetime as dt_mod
|
||||
|
||||
now = dt_mod.now().isoformat(timespec="seconds")
|
||||
from_str = now
|
||||
to_str = now
|
||||
else:
|
||||
from_str = from_date
|
||||
to_str = to_date
|
||||
|
||||
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
|
||||
print(
|
||||
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -350,10 +350,22 @@ class Config:
|
||||
if from_env and to_env:
|
||||
return f"{from_env}--{to_env}"
|
||||
|
||||
if (
|
||||
cls._app
|
||||
and cls._app.period_dynamic
|
||||
and cls._app.period_last_used_from
|
||||
and cls._app.period_last_used_to
|
||||
):
|
||||
nf, nt = compute_next_period(
|
||||
cls._app.period_last_used_from,
|
||||
cls._app.period_last_used_to,
|
||||
cls._app.period_precision or "date",
|
||||
)
|
||||
return f"{nf}--{nt}"
|
||||
|
||||
if cls._app and cls._app.period_default_from and cls._app.period_default_to:
|
||||
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
|
||||
|
||||
# fallback: текущий месяц
|
||||
today = date.today()
|
||||
start = today.replace(day=1)
|
||||
if today.month == 12:
|
||||
@@ -373,3 +385,50 @@ class Config:
|
||||
raise ValueError(
|
||||
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
|
||||
)
|
||||
|
||||
|
||||
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]:
|
||||
"""Compute the next report period based on the last committed period.
|
||||
|
||||
- For a full calendar month → next full calendar month.
|
||||
- For an arbitrary range → same duration, starting the day after last_to.
|
||||
- For datetime precision → same duration, starting 1 second after last_to.
|
||||
"""
|
||||
from datetime import datetime as dt_mod
|
||||
|
||||
if precision == "datetime":
|
||||
fmt = "%Y-%m-%dT%H:%M:%S"
|
||||
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
|
||||
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
|
||||
duration = to_dt - from_dt
|
||||
next_from_dt = to_dt + timedelta(seconds=1)
|
||||
next_to_dt = next_from_dt + duration
|
||||
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
|
||||
|
||||
from_parts = last_from.split("-")
|
||||
to_parts = last_to.split("-")
|
||||
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
|
||||
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
|
||||
|
||||
first_of_month = from_d.replace(day=1)
|
||||
if from_d == first_of_month:
|
||||
if to_d.month == 12:
|
||||
last_of_month = date(to_d.year, 12, 31)
|
||||
else:
|
||||
next_first = date(to_d.year, to_d.month + 1, 1)
|
||||
last_of_month = next_first - timedelta(days=1)
|
||||
if to_d == last_of_month:
|
||||
if to_d.month == 12:
|
||||
nstart = date(to_d.year + 1, 1, 1)
|
||||
else:
|
||||
nstart = date(to_d.year, to_d.month + 1, 1)
|
||||
if nstart.month == 12:
|
||||
nend = date(nstart.year, 12, 31)
|
||||
else:
|
||||
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
|
||||
return nstart.isoformat(), nend.isoformat()
|
||||
|
||||
duration = to_d - from_d
|
||||
next_from = to_d + timedelta(days=1)
|
||||
next_to = next_from + duration
|
||||
return next_from.isoformat(), next_to.isoformat()
|
||||
|
||||
@@ -149,3 +149,38 @@ def resolve_output_path(
|
||||
arg = f"{arg}.{default_format}"
|
||||
|
||||
return arg
|
||||
|
||||
|
||||
def save_period_to_config(
|
||||
config_path: str,
|
||||
from_str: str,
|
||||
to_str: str,
|
||||
precision: str,
|
||||
dynamic: bool,
|
||||
) -> None:
|
||||
"""Save committed period to YAML config file.
|
||||
|
||||
Writes period.last_used.from / period.last_used.to.
|
||||
When dynamic=False, also overwrites period.default_from / period.default_to.
|
||||
|
||||
Preserves all other sections unchanged. Creates config file if missing.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
path = Path(config_path)
|
||||
raw: dict = {}
|
||||
if path.exists():
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
|
||||
period = raw.setdefault("period", {})
|
||||
period["last_used"] = {"from": from_str, "to": to_str}
|
||||
|
||||
if not dynamic:
|
||||
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
|
||||
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
|
||||
|
||||
ensure_config_dir(path.parent)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
path.chmod(0o600)
|
||||
|
||||
Reference in New Issue
Block a user