diff --git a/apps/api/src/services/weekly_report_service.py b/apps/api/src/services/weekly_report_service.py index 1366eea3..1a2bd824 100644 --- a/apps/api/src/services/weekly_report_service.py +++ b/apps/api/src/services/weekly_report_service.py @@ -20,13 +20,17 @@ Weekly Report Service - Phase 21.3 定期報告 """ import asyncio +import json import subprocess from datetime import datetime, timedelta +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen from typing import Protocol, runtime_checkable import structlog from zoneinfo import ZoneInfo +from src.core.config import settings from src.services.stats_service import StatsService, get_stats_service from src.services.k3s_monitor_service import K3sMonitorService, get_k3s_monitor_service from src.services.telegram_gateway import WeeklyReportMessage, get_telegram_gateway @@ -112,7 +116,7 @@ class WeeklyReportService: returncode=result.returncode, stderr=result.stderr[-300:], ) - return 0, 0, False + return self._get_gitea_commit_stats(since) commits = len(result.stdout.strip().split("\n")) if result.stdout.strip() else 0 # 取得部署次數 (計算含 "deploy" 或 "CD" 的 commits) @@ -129,12 +133,56 @@ class WeeklyReportService: returncode=result_deploy.returncode, stderr=result_deploy.stderr[-300:], ) - return commits, 0, False + return self._get_gitea_commit_stats(since) deploys = len(result_deploy.stdout.strip().split("\n")) if result_deploy.stdout.strip() else 0 return commits, deploys, True except Exception as e: logger.warning("git_stats_failed", error=str(e)) + return self._get_gitea_commit_stats(since) + + def _get_gitea_commit_stats(self, since: datetime) -> tuple[int, int, bool]: + """從 Gitea commits API 讀取週報開發活動統計。""" + api_url = settings.GITEA_API_URL.rstrip("/") + owner = quote(str(settings.GITEA_REPO_OWNER), safe="") + repo = quote(str(settings.GITEA_REPO_NAME), safe="") + since_utc = since.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z") + headers = {"Accept": "application/json", "User-Agent": "awoooi-weekly-report/1.0"} + if settings.GITEA_API_TOKEN: + headers["Authorization"] = f"token {settings.GITEA_API_TOKEN}" + + commits = 0 + deploys = 0 + page = 1 + limit = 50 + try: + while page <= 20: + query = urlencode({ + "sha": "main", + "since": since_utc, + "page": page, + "limit": limit, + }) + url = f"{api_url}/api/v1/repos/{owner}/{repo}/commits?{query}" + request = Request(url, headers=headers) + with urlopen(request, timeout=8) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, list): + logger.warning("gitea_git_stats_unexpected_payload", payload_type=type(payload).__name__) + return 0, 0, False + commits += len(payload) + for item in payload: + commit = item.get("commit") if isinstance(item, dict) else {} + message = str((commit or {}).get("message") or "") + subject = message.splitlines()[0].lower() + if "deploy" in subject or subject.startswith("chore(cd):"): + deploys += 1 + if len(payload) < limit: + break + page += 1 + return commits, deploys, True + except Exception as exc: + logger.warning("gitea_git_stats_failed", error=str(exc)) return 0, 0, False async def generate_report(self) -> WeeklyReportMessage: diff --git a/apps/api/tests/test_report_generation_service.py b/apps/api/tests/test_report_generation_service.py index 31a5ba3c..c6f8706a 100644 --- a/apps/api/tests/test_report_generation_service.py +++ b/apps/api/tests/test_report_generation_service.py @@ -58,12 +58,33 @@ class TestWeeklyReportGitStats: ) svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (0, 0, False)) commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) assert commits == 0 assert deploys == 0 assert source_ok is False + def test_git_log_failure_uses_gitea_fallback(self, monkeypatch): + class Result: + returncode = 128 + stdout = "" + stderr = "fatal: not a git repository" + + monkeypatch.setattr( + weekly_report_module.subprocess, + "run", + lambda *args, **kwargs: Result(), + ) + + svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (12, 4, True)) + commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) + + assert commits == 12 + assert deploys == 4 + assert source_ok is True + def test_git_deploy_log_failure_marks_source_failed_after_commits(self, monkeypatch): class CommitResult: returncode = 0 @@ -84,12 +105,48 @@ class TestWeeklyReportGitStats: monkeypatch.setattr(weekly_report_module.subprocess, "run", fake_run) svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (0, 0, False)) commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) - assert commits == 2 + assert commits == 0 assert deploys == 0 assert source_ok is False + def test_gitea_commit_stats_counts_deploy_markers(self, monkeypatch): + payload = [ + {"commit": {"message": "chore(cd): deploy abc123 [skip ci]\n"}}, + {"commit": {"message": "fix(api): repair source health\n"}}, + {"commit": {"message": "docs(logbook): record deploy verification [skip ci]\n"}}, + ] + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self): + return weekly_report_module.json.dumps(payload).encode("utf-8") + + def fake_urlopen(request, timeout): + assert "/api/v1/repos/wooo/awoooi/commits?" in request.full_url + assert timeout == 8 + return Response() + + monkeypatch.setattr(weekly_report_module, "urlopen", fake_urlopen) + monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_URL", "https://gitea.example.test") + monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_OWNER", "wooo") + monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_NAME", "awoooi") + monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_TOKEN", "") + + svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) + commits, deploys, source_ok = svc._get_gitea_commit_stats(datetime.now(_TZ_TAIPEI)) + + assert commits == 3 + assert deploys == 2 + assert source_ok is True + # ============================================================================= # DailyKpi 計算屬性