feat: fold bot-bottle-orchestrator into bot_bottle/orchestrator subpackage
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
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user