From ee01a13129df81b6f40c1e7208515a557b79819a Mon Sep 17 00:00:00 2001 From: quantlab Date: Fri, 6 Mar 2026 20:01:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(integration):=20Gitea=20+=20Vikunja=20+=20?= =?UTF-8?q?CI=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#task-192=20#task-193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 10 ++ integrations/__init__.py | 1 + integrations/ci_monitor.py | 68 ++++++++++++ integrations/gitea_client.py | 183 +++++++++++++++++++++++++++++++++ integrations/vikunja_client.py | 146 ++++++++++++++++++++++++++ tests/test_integrations.py | 51 +++++++++ 6 files changed, 459 insertions(+) create mode 100644 integrations/__init__.py create mode 100644 integrations/ci_monitor.py create mode 100644 integrations/gitea_client.py create mode 100644 integrations/vikunja_client.py create mode 100644 tests/test_integrations.py diff --git a/config.py b/config.py index b80f549..6ce7ede 100644 --- a/config.py +++ b/config.py @@ -34,3 +34,13 @@ SESSIONS_DIR.mkdir(exist_ok=True) # === Gemini === GEMINI_TOKEN_BUDGET: int = int(os.getenv("GEMINI_TOKEN_BUDGET", "50000")) GEMINI_TIMEOUT: int = int(os.getenv("GEMINI_TIMEOUT", "180")) + +# === Gitea === +GITEA_URL: str = os.getenv("GITEA_URL", "https://git.variet.net") +GITEA_TOKEN: str = os.getenv("GITEA_TOKEN", "") +GITEA_REPO: str = os.getenv("GITEA_REPO", "Variet/variet-agent") + +# === Vikunja === +VIKUNJA_URL: str = os.getenv("VIKUNJA_URL", "https://plan.variet.net") +VIKUNJA_TOKEN: str = os.getenv("VIKUNJA_TOKEN", "") +VIKUNJA_PROJECT_ID: int = int(os.getenv("VIKUNJA_PROJECT_ID", "7")) diff --git a/integrations/__init__.py b/integrations/__init__.py new file mode 100644 index 0000000..402250c --- /dev/null +++ b/integrations/__init__.py @@ -0,0 +1 @@ +"""Integrations package — Gitea, Vikunja, CI.""" diff --git a/integrations/ci_monitor.py b/integrations/ci_monitor.py new file mode 100644 index 0000000..01bc686 --- /dev/null +++ b/integrations/ci_monitor.py @@ -0,0 +1,68 @@ +"""CI Monitor — Gitea commit status 기반 CI 결과 대기. + +Woodpecker CI의 결과를 Gitea commit status API로 폴링합니다. +""" + +import asyncio +import logging + +from integrations.gitea_client import GiteaClient + +logger = logging.getLogger("variet.ci") + + +class CIMonitor: + """CI 결과 모니터링.""" + + def __init__(self, gitea: GiteaClient = None): + self.gitea = gitea or GiteaClient() + + async def wait_for_ci( + self, + sha: str, + timeout: int = 300, + poll_interval: int = 10, + ) -> dict: + """CI 결과 대기. + + Args: + sha: 커밋 SHA + timeout: 최대 대기 시간 (초) + poll_interval: 폴링 간격 (초) + + Returns: + {"status": "success|failure|pending", "details": [...]} + """ + elapsed = 0 + logger.info(f"CI 결과 대기 중... (SHA: {sha[:7]}, timeout: {timeout}s)") + + while elapsed < timeout: + try: + combined = await self.gitea.get_combined_status(sha) + state = combined.get("state", "pending") + statuses = combined.get("statuses", []) or [] + + if state in ("success", "failure", "error"): + logger.info(f"CI 완료: {state} ({len(statuses)}개 체크)") + return { + "status": state, + "details": [ + { + "context": s.get("context", ""), + "status": s.get("status", ""), + "description": s.get("description", ""), + } + for s in statuses + ], + } + + logger.debug(f"CI 진행 중... ({elapsed}s/{timeout}s)") + + except Exception as e: + logger.warning(f"CI 상태 조회 실패: {e}") + + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + logger.warning(f"CI 타임아웃 ({timeout}s)") + return {"status": "timeout", "details": []} diff --git a/integrations/gitea_client.py b/integrations/gitea_client.py new file mode 100644 index 0000000..dbc0fc6 --- /dev/null +++ b/integrations/gitea_client.py @@ -0,0 +1,183 @@ +"""Gitea API 클라이언트. + +git.variet.net API를 통해 레포지토리, 브랜치, PR, 커밋을 관리합니다. +""" + +import logging +from typing import Optional + +import httpx + +import config + +logger = logging.getLogger("variet.gitea") + + +class GiteaClient: + """Gitea REST API 비동기 클라이언트.""" + + def __init__( + self, + base_url: str = None, + token: str = None, + repo: str = None, + ): + self.base_url = (base_url or config.GITEA_URL).rstrip("/") + self.api_url = f"{self.base_url}/api/v1" + self.token = token or config.GITEA_TOKEN + self.repo = repo or config.GITEA_REPO + self.headers = { + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + } + + async def _get(self, path: str, params: dict = None) -> dict | list: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{self.api_url}{path}", + headers=self.headers, + params=params, + ) + resp.raise_for_status() + return resp.json() + + async def _post(self, path: str, data: dict = None) -> dict: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{self.api_url}{path}", + headers=self.headers, + json=data, + ) + resp.raise_for_status() + return resp.json() + + # === Commits === + + async def get_commits(self, limit: int = 10, branch: str = "main") -> list[dict]: + """최근 커밋 조회.""" + commits = await self._get( + f"/repos/{self.repo}/commits", + params={"limit": limit, "sha": branch}, + ) + return [ + { + "sha": c["sha"][:7], + "message": c["commit"]["message"].split("\n")[0], + "author": c["commit"]["author"]["name"], + "date": c["commit"]["author"]["date"], + } + for c in commits + ] + + # === Branches === + + async def create_branch(self, name: str, from_branch: str = "main") -> dict: + """새 브랜치 생성.""" + result = await self._post( + f"/repos/{self.repo}/branches", + data={"new_branch_name": name, "old_branch_name": from_branch}, + ) + logger.info(f"브랜치 생성: {name} (from {from_branch})") + return result + + async def list_branches(self) -> list[str]: + """브랜치 목록.""" + branches = await self._get(f"/repos/{self.repo}/branches") + return [b["name"] for b in branches] + + # === Pull Requests === + + async def create_pr( + self, + title: str, + head: str, + base: str = "main", + body: str = "", + ) -> dict: + """PR 생성.""" + result = await self._post( + f"/repos/{self.repo}/pulls", + data={ + "title": title, + "head": head, + "base": base, + "body": body, + }, + ) + pr_number = result.get("number") + logger.info(f"PR #{pr_number} 생성: {title}") + return result + + async def list_prs(self, state: str = "open") -> list[dict]: + """PR 목록 조회.""" + prs = await self._get( + f"/repos/{self.repo}/pulls", + params={"state": state}, + ) + return [ + { + "number": p["number"], + "title": p["title"], + "state": p["state"], + "user": p["user"]["login"], + } + for p in prs + ] + + async def merge_pr(self, pr_number: int, merge_type: str = "merge") -> dict: + """PR 머지.""" + result = await self._post( + f"/repos/{self.repo}/pulls/{pr_number}/merge", + data={"Do": merge_type}, + ) + logger.info(f"PR #{pr_number} 머지 완료") + return result + + # === Issues === + + async def list_issues(self, state: str = "open") -> list[dict]: + """이슈 목록 조회.""" + issues = await self._get( + f"/repos/{self.repo}/issues", + params={"state": state, "type": "issues"}, + ) + return [ + { + "number": i["number"], + "title": i["title"], + "state": i["state"], + } + for i in issues + ] + + async def create_issue(self, title: str, body: str = "") -> dict: + """이슈 생성.""" + result = await self._post( + f"/repos/{self.repo}/issues", + data={"title": title, "body": body}, + ) + logger.info(f"이슈 #{result.get('number')} 생성: {title}") + return result + + # === Commit Status (CI) === + + async def get_commit_status(self, sha: str) -> list[dict]: + """커밋의 CI 상태 조회.""" + statuses = await self._get( + f"/repos/{self.repo}/statuses/{sha}", + ) + return [ + { + "status": s["status"], + "description": s.get("description", ""), + "context": s.get("context", ""), + "target_url": s.get("target_url", ""), + } + for s in statuses + ] + + async def get_combined_status(self, sha: str) -> dict: + """커밋의 통합 CI 상태 조회.""" + return await self._get( + f"/repos/{self.repo}/commits/{sha}/status", + ) diff --git a/integrations/vikunja_client.py b/integrations/vikunja_client.py new file mode 100644 index 0000000..6b0c5f2 --- /dev/null +++ b/integrations/vikunja_client.py @@ -0,0 +1,146 @@ +"""Vikunja API 클라이언트. + +plan.variet.net API를 통해 태스크를 관리합니다. +vikunja_helper.py의 async 버전 — safe_update 패턴 유지. +""" + +import logging +from typing import Optional + +import httpx + +import config + +logger = logging.getLogger("variet.vikunja") + + +class VikunjaClient: + """Vikunja REST API 비동기 클라이언트.""" + + def __init__( + self, + base_url: str = None, + token: str = None, + project_id: int = None, + ): + self.base_url = (base_url or config.VIKUNJA_URL).rstrip("/") + self.api_url = f"{self.base_url}/api/v1" + self.token = token or config.VIKUNJA_TOKEN + self.project_id = project_id or config.VIKUNJA_PROJECT_ID + self.headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + async def _get(self, path: str, params: dict = None) -> dict | list: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{self.api_url}{path}", + headers=self.headers, + params=params, + ) + resp.raise_for_status() + return resp.json() + + async def _post(self, path: str, data: dict = None) -> dict: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{self.api_url}{path}", + headers=self.headers, + json=data, + ) + resp.raise_for_status() + return resp.json() + + async def _put(self, path: str, data: dict = None) -> dict: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.put( + f"{self.api_url}{path}", + headers=self.headers, + json=data, + ) + resp.raise_for_status() + return resp.json() + + # === Tasks === + + async def list_tasks(self, filter_: str = "all") -> list[dict]: + """프로젝트 태스크 목록 조회.""" + tasks = await self._get( + f"/projects/{self.project_id}/tasks", + params={"per_page": 100}, + ) + + if filter_ == "todo": + tasks = [t for t in tasks if not t["done"]] + elif filter_ == "done": + tasks = [t for t in tasks if t["done"]] + + tasks.sort(key=lambda t: t["id"]) + return [ + { + "id": t["id"], + "title": t["title"], + "description": (t.get("description") or "")[:100], + "done": t["done"], + "labels": [l["title"] for l in (t.get("labels") or [])], + } + for t in tasks + ] + + async def get_task(self, task_id: int) -> dict: + """태스크 상세 조회.""" + return await self._get(f"/tasks/{task_id}") + + async def safe_update_task(self, task_id: int, updates: dict) -> dict: + """안전한 태스크 업데이트 — GET → 보존 → POST. + + Vikunja API는 POST 시 누락된 필드를 빈값으로 덮어쓰므로 + 반드시 기존 필드를 보존해야 합니다. + """ + task = await self.get_task(task_id) + safe_body = { + "title": task.get("title", ""), + "description": task.get("description", ""), + "priority": task.get("priority", 0), + "done": task.get("done", False), + } + safe_body.update(updates) + result = await self._post(f"/tasks/{task_id}", safe_body) + logger.info(f"태스크 #{task_id} 업데이트: {updates}") + return result + + async def mark_done(self, task_id: int) -> dict: + """태스크 완료 처리.""" + result = await self.safe_update_task(task_id, {"done": True}) + logger.info(f"태스크 #{task_id} 완료 처리") + return result + + async def create_task( + self, + title: str, + description: str = "", + done: bool = False, + ) -> dict: + """새 태스크 생성.""" + payload = {"title": title, "description": description} + result = await self._put( + f"/projects/{self.project_id}/tasks", + data=payload, + ) + task_id = result["id"] + logger.info(f"태스크 #{task_id} 생성: {title}") + + if done: + result = await self.safe_update_task(task_id, {"done": True}) + + return result + + async def add_comment(self, task_id: int, comment: str) -> dict: + """태스크에 코멘트 추가.""" + result = await self._put( + f"/tasks/{task_id}/comments", + data={"comment": comment}, + ) + logger.info(f"태스크 #{task_id} 코멘트 추가") + return result diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..532eeab --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,51 @@ +"""Integration test: Gitea + Vikunja clients.""" +import asyncio +import sys +import os +import io + +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from integrations.gitea_client import GiteaClient +from integrations.vikunja_client import VikunjaClient + + +async def test_gitea(): + print("=== Gitea API Test ===") + c = GiteaClient() + + commits = await c.get_commits(limit=3) + print(f" Recent commits ({len(commits)}):") + for x in commits: + print(f" {x['sha']} {x['message']}") + + branches = await c.list_branches() + print(f" Branches: {branches}") + + prs = await c.list_prs() + print(f" Open PRs: {len(prs)}") + print(" Gitea OK") + + +async def test_vikunja(): + print("\n=== Vikunja API Test ===") + c = VikunjaClient() + + tasks = await c.list_tasks("todo") + print(f" TODO tasks ({len(tasks)}):") + for t in tasks: + print(f" #{t['id']} {t['title']}") + print(" Vikunja OK") + + +async def main(): + await test_gitea() + await test_vikunja() + print("\n All integration tests passed!") + + +if __name__ == "__main__": + asyncio.run(main())