314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""Gitea API client and forge adapter (PRD prd-new: fold orchestrator).
|
|
|
|
`GiteaClient` is a thin HTTP wrapper (stdlib `urllib.request` only — no
|
|
new runtime dependencies). `GiteaForge` composes a client and exposes
|
|
the forge protocol used by the orchestrator's sidecar and lifecycle.
|
|
|
|
Required Gitea token scopes:
|
|
- Repository: Read & Write (issues, comments, PR descriptions)
|
|
- Organization: Read (org membership check)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
_TIMEOUT_SECS = 30
|
|
|
|
|
|
class GiteaClient:
|
|
"""Low-level HTTP wrapper for the Gitea REST API."""
|
|
|
|
def __init__(
|
|
self, *, api_url: str, owner: str, repo: str, token: str
|
|
) -> None:
|
|
self._base = api_url.rstrip("/")
|
|
self._owner = owner
|
|
self._repo = repo
|
|
self._headers = {
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
url = f"{self._base}{path}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(
|
|
url, data=data, headers=self._headers, method=method
|
|
)
|
|
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
|
|
raw = resp.read()
|
|
return json.loads(raw) if raw else None
|
|
|
|
def is_org_member(self, org: str, username: str) -> bool:
|
|
url = f"{self._base}/orgs/{org}/members/{username}"
|
|
req = urllib.request.Request(url, headers=self._headers, method="GET")
|
|
try:
|
|
urllib.request.urlopen(req, timeout=_TIMEOUT_SECS).close()
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return False
|
|
|
|
def get_issue(self, number: int) -> dict[str, Any]:
|
|
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}")
|
|
|
|
def get_pull(self, number: int) -> dict[str, Any]:
|
|
return self._request("GET", f"/repos/{self._owner}/{self._repo}/pulls/{number}")
|
|
|
|
def list_comments(self, number: int) -> list[dict[str, Any]]:
|
|
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}/comments")
|
|
|
|
def create_comment(self, number: int, body: str) -> None:
|
|
self._request(
|
|
"POST",
|
|
f"/repos/{self._owner}/{self._repo}/issues/{number}/comments",
|
|
{"body": body},
|
|
)
|
|
|
|
def update_issue(self, number: int, body: str) -> None:
|
|
self._request(
|
|
"PATCH",
|
|
f"/repos/{self._owner}/{self._repo}/issues/{number}",
|
|
{"body": body},
|
|
)
|
|
|
|
|
|
class GiteaForge:
|
|
"""Adapts `GiteaClient` to the forge protocol expected by the orchestrator.
|
|
|
|
The forge protocol is duck-typed: any object with `is_org_member`,
|
|
`read_issue`, `read_pr`, `read_comments`, `post_comment`, and
|
|
`update_description` methods satisfies it.
|
|
"""
|
|
|
|
def __init__(self, client: GiteaClient) -> None:
|
|
self._client = client
|
|
|
|
def is_org_member(self, org: str, username: str) -> bool:
|
|
return self._client.is_org_member(org, username)
|
|
|
|
def read_issue(self, number: int) -> dict[str, Any]:
|
|
return self._client.get_issue(number)
|
|
|
|
def read_pr(self, number: int) -> dict[str, Any]:
|
|
return self._client.get_pull(number)
|
|
|
|
def read_comments(self, number: int) -> list[dict[str, Any]]:
|
|
return self._client.list_comments(number)
|
|
|
|
def post_comment(self, number: int, body: str) -> None:
|
|
self._client.create_comment(number, body)
|
|
|
|
def update_description(self, number: int, body: str) -> None:
|
|
self._client.update_issue(number, body)
|