from __future__ import annotations import re from dataclasses import dataclass from typing import Any, Optional from urllib.parse import quote import httpx from config import settings @dataclass class GitLabTask: id: int iid: int title: str reference: str web_url: str project_path: str source: str = "unknown" labels: list[str] | None = None def to_description(self) -> str: from services.description_formatter import format_professional_description return format_professional_description(self) class GitLabClient: def __init__(self) -> None: self.base_url = settings.gitlab_url.rstrip("/") self.token = (settings.gitlab_token or "").strip().strip("\"'") self._username: Optional[str] = None def _headers(self) -> dict[str, str]: return {"PRIVATE-TOKEN": self.token} def _auth_error_message(self, response: httpx.Response) -> str: detail = "" try: payload = response.json() detail = ( payload.get("error_description") or payload.get("message") or payload.get("error") or "" ) except Exception: # noqa: BLE001 detail = (response.text or "")[:200] detail = str(detail).strip() lower = detail.lower() if response.status_code == 401: if "revok" in lower: return ( "GitLab token 已撤銷或失效。請到 GitLab → Preferences → Access Tokens " "重新建立 Personal Access Token(勾選 api),再到設定頁貼上新 token。" ) return ( "GitLab token 無效(401)。請確認已貼上新的 Personal Access Token," f"且 URL 為 {self.base_url}。{(' 詳情: ' + detail) if detail else ''}" ) if response.status_code == 403: return f"GitLab token 權限不足(403)。請勾選 api scope。{(' 詳情: ' + detail) if detail else ''}" return f"GitLab API error {response.status_code}: {detail or response.reason_phrase}" async def _get_current_username(self) -> str: if self._username: return self._username async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: response = await client.get(f"{self.base_url}/api/v4/user") if response.status_code in {401, 403}: raise ValueError(self._auth_error_message(response)) response.raise_for_status() self._username = response.json()["username"] return self._username def _issue_to_task(self, issue: dict[str, Any], source: str) -> GitLabTask: refs = issue.get("references") or {} return GitLabTask( id=issue["id"], iid=issue["iid"], title=issue["title"], reference=refs.get("full") or f"#{issue['iid']}", web_url=issue.get("web_url", ""), project_path=refs.get("relative", ""), source=source, labels=issue.get("labels") or [], ) async def _fetch_project_issues( self, project_path: str, *, source: str, extra_params: Optional[dict[str, Any]] = None, ) -> list[GitLabTask]: encoded = quote(project_path, safe="") params: dict[str, Any] = { "state": "opened", "per_page": 100, "order_by": "updated_at", "sort": "desc", } if extra_params: params.update(extra_params) async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: response = await client.get( f"{self.base_url}/api/v4/projects/{encoded}/issues", params=params, ) response.raise_for_status() issues = response.json() return [self._issue_to_task(issue, source) for issue in issues] async def get_board_bug_tasks(self) -> list[GitLabTask]: username = await self._get_current_username() labels = ",".join(settings.gitlab_board_label_list) return await self._fetch_project_issues( settings.gitlab_project_path, source="board_bug", extra_params={ "assignee_username": username, "labels": labels, }, ) async def _pick_current_milestone( self, active_milestones: list[dict[str, Any]], closed_milestones: list[dict[str, Any]], ) -> Optional[dict[str, Any]]: if settings.gitlab_milestone_title: for milestone in active_milestones: if milestone.get("title") == settings.gitlab_milestone_title: return milestone return {"title": settings.gitlab_milestone_title, "state": "configured"} version_re = re.compile(r"^SCC_v", re.IGNORECASE) active_versions = [ milestone for milestone in active_milestones if milestone.get("state") == "active" and "backlog" not in milestone.get("title", "").lower() and version_re.match(milestone.get("title", "")) ] if not active_versions: return None closed_versions = [ milestone for milestone in closed_milestones if version_re.match(milestone.get("title", "")) ] latest_closed_version = 0 if closed_versions: latest_closed_version = max( self._milestone_version_key(milestone.get("title", "")) for milestone in closed_versions ) # 目前衝刺 = 最新已關閉版本之後、版本號最小的 active milestone # 例:a10 已關閉 → 選 a11(不是規劃中的 a12) successors = [ milestone for milestone in active_versions if self._milestone_version_key(milestone.get("title", "")) > latest_closed_version ] if successors: successors.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", ""))) return successors[0] active_versions.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", ""))) return active_versions[0] def _milestone_version_key(self, title: str) -> int: match = re.search(r"a(\d+)$", title, re.IGNORECASE) return int(match.group(1)) if match else 0 async def get_milestone_tasks(self) -> tuple[Optional[str], list[GitLabTask]]: encoded = quote(settings.gitlab_project_path, safe="") username = await self._get_current_username() async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client: active_resp = await client.get( f"{self.base_url}/api/v4/projects/{encoded}/milestones", params={"state": "active", "per_page": 50}, ) active_resp.raise_for_status() active_milestones = active_resp.json() closed_resp = await client.get( f"{self.base_url}/api/v4/projects/{encoded}/milestones", params={"state": "closed", "per_page": 20, "order_by": "updated_at", "sort": "desc"}, ) closed_resp.raise_for_status() closed_milestones = closed_resp.json() milestone = await self._pick_current_milestone(active_milestones, closed_milestones) if not milestone: return None, [] title = milestone["title"] tasks = await self._fetch_project_issues( settings.gitlab_project_path, source="milestone", extra_params={ "assignee_username": username, "milestone": title, }, ) return title, tasks async def get_assigned_tasks(self) -> list[GitLabTask]: if not self.token: raise ValueError("GITLAB_TOKEN is not configured") board_tasks = await self.get_board_bug_tasks() milestone_title, milestone_tasks = await self.get_milestone_tasks() merged: dict[int, GitLabTask] = {} for task in board_tasks: merged[task.id] = task for task in milestone_tasks: merged.setdefault(task.id, task) tasks = list(merged.values()) tasks.sort(key=lambda t: (0 if t.source == "board_bug" else 1, t.reference)) return tasks async def get_task_sources(self) -> dict[str, Any]: board_tasks = await self.get_board_bug_tasks() milestone_title, milestone_tasks = await self.get_milestone_tasks() merged = await self.get_assigned_tasks() return { "board": { "project": settings.gitlab_project_path, "board_id": settings.gitlab_board_id, "labels": settings.gitlab_board_label_list, "count": len(board_tasks), "tasks": [t.__dict__ for t in board_tasks], }, "milestone": { "title": milestone_title, "count": len(milestone_tasks), "tasks": [t.__dict__ for t in milestone_tasks], }, "merged_count": len(merged), "tasks": [t.__dict__ for t in merged], }