eight-hourr/backend/services/gitlab_client.py

252 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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],
}