refactor: extract focused clients from GiteaClient (Slices 1-6)
- Create gitea/issues_client.py with IssuesClient class (9 methods) - Create gitea/prs_client.py with PullRequestsClient class (17 methods) - Create gitea/files_client.py with FilesClient class (4 methods) - Create gitea/notifications_client.py with NotificationsClient class (2 methods) - Create gitea/repos_client.py with ReposClient class (2 methods) - Create gitea/__init__.py to export all client classes - Remove delegation methods from GiteaClient (now ~70 lines) - Update all callers to use sub-clients (client.issues, client.prs, etc.) - Update test files to mock sub-client attributes GiteaClient is now a facade that provides access to focused sub-clients: - repos: Repository operations (ReposClient) - issues: Issue operations (IssuesClient) - prs: Pull request operations (PullRequestsClient) - files: File and git ref operations (FilesClient) - notifications: Notification operations (NotificationsClient) Refs: #godclass-refactor
This commit is contained in:
+35
-469
@@ -1,25 +1,28 @@
|
||||
import httpx
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
logger: logging.Logger = logging.getLogger("gitea.client")
|
||||
|
||||
from .config import GITEA_URL, GITEA_TOKEN, GITEA_ORG_FILTER
|
||||
from .models import (
|
||||
UserModel,
|
||||
LabelModel,
|
||||
RepositoryModel,
|
||||
IssueModel,
|
||||
PullRequestModel,
|
||||
CommentModel,
|
||||
PullRequestFileModel,
|
||||
)
|
||||
from .files_client import FilesClient
|
||||
from .issues_client import IssuesClient
|
||||
from .notifications_client import NotificationsClient
|
||||
from .prs_client import PullRequestsClient
|
||||
from .repos_client import ReposClient
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""HTTP client for Gitea API v1."""
|
||||
"""HTTP client for Gitea API v1.
|
||||
|
||||
This is a facade class that provides access to focused sub-clients
|
||||
for different API domains:
|
||||
- repos: Repository operations (ReposClient)
|
||||
- issues: Issue operations (IssuesClient)
|
||||
- prs: Pull request operations (PullRequestsClient)
|
||||
- files: File and git ref operations (FilesClient)
|
||||
- notifications: Notification operations (NotificationsClient)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url: str = GITEA_URL.rstrip("/")
|
||||
@@ -28,6 +31,25 @@ class GiteaClient:
|
||||
"Accept": "application/json",
|
||||
}
|
||||
self.client: httpx.Client = httpx.Client(headers=self.headers)
|
||||
self.repos: ReposClient = ReposClient(
|
||||
self.base_url, self.client, GITEA_ORG_FILTER
|
||||
)
|
||||
self.issues: IssuesClient = IssuesClient(
|
||||
self.base_url,
|
||||
self.client,
|
||||
get_user=self.repos.get_authenticated_user,
|
||||
get_repos=self.repos.list_all_user_repos,
|
||||
)
|
||||
self.prs: PullRequestsClient = PullRequestsClient(
|
||||
self.base_url,
|
||||
self.client,
|
||||
get_user=self.repos.get_authenticated_user,
|
||||
get_repos=self.repos.list_all_user_repos,
|
||||
)
|
||||
self.files: FilesClient = FilesClient(self.base_url, self.client)
|
||||
self.notifications: NotificationsClient = NotificationsClient(
|
||||
self.base_url, self.client, GITEA_ORG_FILTER
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
@@ -44,459 +66,3 @@ class GiteaClient:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_authenticated_user(self) -> UserModel:
|
||||
try:
|
||||
response = self.client.get(f"{self.base_url}/api/v1/user")
|
||||
response.raise_for_status()
|
||||
return UserModel(**response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting authenticated user: {e}", exc_info=True)
|
||||
raise RuntimeError(f"Could not get authenticated user: {e}") from e
|
||||
|
||||
def list_all_user_repos(self) -> list[RepositoryModel]:
|
||||
try:
|
||||
url = f"{self.base_url}/api/v1/user/repos"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
repos: list[dict[str, Any]] = response.json()
|
||||
# Filter to ONLY configured organization repos, include mirrors
|
||||
seen: set[str] = set()
|
||||
result: list[RepositoryModel] = []
|
||||
for r in repos:
|
||||
full_name = r.get("full_name", "")
|
||||
if (
|
||||
full_name
|
||||
and full_name not in seen
|
||||
and (r.get("owner") or {}).get("login") == GITEA_ORG_FILTER
|
||||
):
|
||||
seen.add(full_name)
|
||||
result.append(RepositoryModel(**r))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing user repos: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def list_repo_issues(
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[IssueModel]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return [IssueModel(**item) for item in response.json()]
|
||||
|
||||
def list_repo_pull_requests(
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[PullRequestModel]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}"
|
||||
response = self.client.get(url)
|
||||
if response.status_code == 404:
|
||||
return []
|
||||
response.raise_for_status()
|
||||
return [PullRequestModel(**item) for item in response.json()]
|
||||
|
||||
def get_pull_request(
|
||||
self, owner: str, repo: str, pull_number: int
|
||||
) -> PullRequestModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return PullRequestModel(**response.json())
|
||||
|
||||
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return IssueModel(**response.json())
|
||||
|
||||
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||
data: dict[str, str] = {"state": "closed"}
|
||||
response = self.client.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return IssueModel(**response.json())
|
||||
|
||||
def close_pull_request(
|
||||
self, owner: str, repo: str, pull_number: int
|
||||
) -> PullRequestModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||
data: dict[str, str] = {"state": "closed"}
|
||||
response = self.client.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return PullRequestModel(**response.json())
|
||||
|
||||
def get_issue_comments(
|
||||
self, owner: str, repo: str, issue_number: int
|
||||
) -> list[CommentModel]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return [CommentModel(**item) for item in response.json()]
|
||||
|
||||
def get_pull_request_comments(
|
||||
self, owner: str, repo: str, pull_number: int
|
||||
) -> list[CommentModel]:
|
||||
url = (
|
||||
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments"
|
||||
)
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return [CommentModel(**item) for item in response.json()]
|
||||
|
||||
def get_pull_request_diff(self, owner: str, repo: str, pull_number: int) -> str:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_pull_request_files(
|
||||
self, owner: str, repo: str, pull_number: int
|
||||
) -> list[PullRequestFileModel]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files"
|
||||
response = self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return [PullRequestFileModel(**item) for item in response.json()]
|
||||
|
||||
def list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]:
|
||||
try:
|
||||
user = self.get_authenticated_user()
|
||||
if not user:
|
||||
return []
|
||||
username: str = user.login
|
||||
if owner and repo:
|
||||
response = self.client.get(
|
||||
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return [IssueModel(**item) for item in response.json()]
|
||||
all_issues: list[IssueModel] = []
|
||||
repos = self.list_all_user_repos()
|
||||
for r in repos:
|
||||
repo_owner = r.owner
|
||||
repo_name = r.name
|
||||
resp = self.client.get(
|
||||
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues",
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
for item in resp.json():
|
||||
issue = IssueModel(**item)
|
||||
# Backfill repository if Gitea omitted it
|
||||
if issue.repository is None:
|
||||
issue = issue.model_copy(update={"repository": r})
|
||||
all_issues.append(issue)
|
||||
return all_issues
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing assigned issues: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def list_assigned_pull_requests(
|
||||
self, owner: str = "", repo: str = ""
|
||||
) -> list[PullRequestModel]:
|
||||
"""List all pull requests assigned to or authored by the authenticated user."""
|
||||
try:
|
||||
user = self.get_authenticated_user()
|
||||
if not user:
|
||||
return []
|
||||
username: str = user.login
|
||||
if owner and repo:
|
||||
response = self.client.get(
|
||||
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open",
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return []
|
||||
response.raise_for_status()
|
||||
all_prs: list[PullRequestModel] = [
|
||||
PullRequestModel(**pr) for pr in response.json()
|
||||
]
|
||||
return [
|
||||
pr
|
||||
for pr in all_prs
|
||||
if (pr.assignee and pr.assignee.login == username)
|
||||
or (pr.user and pr.user.login == username)
|
||||
]
|
||||
all_prs: list[PullRequestModel] = []
|
||||
repos = self.list_all_user_repos()
|
||||
for r in repos:
|
||||
repo_owner = r.owner
|
||||
repo_name = r.name
|
||||
resp = self.client.get(
|
||||
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open",
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
for pr_data in resp.json():
|
||||
pr = PullRequestModel(**pr_data)
|
||||
if (pr.assignee and pr.assignee.login == username) or (
|
||||
pr.user and pr.user.login == username
|
||||
):
|
||||
# Backfill repository if Gitea omitted it
|
||||
if pr.repository is None:
|
||||
pr = pr.model_copy(update={"repository": r})
|
||||
all_prs.append(pr)
|
||||
return all_prs
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def create_pull_request(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
) -> PullRequestModel:
|
||||
try:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls"
|
||||
data: dict[str, str] = {
|
||||
"title": title,
|
||||
"body": description,
|
||||
"head": head,
|
||||
"base": base,
|
||||
}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return PullRequestModel(**response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating pull request: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def update_pull_request(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pull_number: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
state: str | None = None,
|
||||
) -> PullRequestModel:
|
||||
try:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
|
||||
data: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
if body is not None:
|
||||
data["body"] = body
|
||||
if state is not None:
|
||||
data["state"] = state
|
||||
response = self.client.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return PullRequestModel(**response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating pull request: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def create_pr_via_tea(
|
||||
self, owner: str, repo: str, title: str, description: str, head: str, base: str
|
||||
) -> PullRequestModel:
|
||||
return self.create_pull_request(owner, repo, head, base, title, description)
|
||||
|
||||
def approve_pr(
|
||||
self, owner: str, repo: str, pr_number: int, comment: str
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def request_changes_pr(
|
||||
self, owner: str, repo: str, pr_number: int, comment: str
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_pr_reviews(
|
||||
self, owner: str, repo: str, pr_number: int
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
||||
response = self.client.get(url)
|
||||
if response.status_code == 404:
|
||||
return []
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def dismiss_review_pr(
|
||||
self, owner: str, repo: str, pr_number: int, review_id: int, message: str
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
|
||||
data: dict[str, str] = {"message": message}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def assign_issue(
|
||||
self, owner: str, repo: str, issue_number: int, username: str
|
||||
) -> IssueModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
|
||||
data: dict[str, list[str]] = {"assignees": [username]}
|
||||
response = self.client.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return IssueModel(**response.json())
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
) -> IssueModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues"
|
||||
data: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
data["labels"] = labels
|
||||
if assignees:
|
||||
data["assignees"] = assignees
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return IssueModel(**response.json())
|
||||
|
||||
def add_comment(
|
||||
self, owner: str, repo: str, issue_number: int, body: str
|
||||
) -> CommentModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
data: dict[str, str] = {"body": body}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return CommentModel(**response.json())
|
||||
|
||||
def add_label(
|
||||
self, owner: str, repo: str, issue_number: int, label: str
|
||||
) -> LabelModel:
|
||||
url = (
|
||||
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels"
|
||||
)
|
||||
data: list[str] = [label]
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return LabelModel(**response.json())
|
||||
|
||||
def add_label_pr(
|
||||
self, owner: str, repo: str, pr_number: int, label: str
|
||||
) -> LabelModel:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels"
|
||||
data: list[str] = [label]
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return LabelModel(**response.json())
|
||||
|
||||
def update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}"
|
||||
data: dict[str, str] = {"sha": sha}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs"
|
||||
data: dict[str, str] = {"ref": ref, "sha": sha}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def update_file(
|
||||
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||
data: dict[str, str] = {
|
||||
"message": message,
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"branch": branch,
|
||||
"new_branch": f"{branch}-update-{path}",
|
||||
}
|
||||
response = self.client.put(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_file_content(
|
||||
self, owner: str, repo: str, path: str, ref: str = "master"
|
||||
) -> str | list[str]:
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||
params: dict[str, str] = {"ref": ref}
|
||||
response = self.client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
item.get("content", "") for item in data if item.get("type") == "file"
|
||||
]
|
||||
return (
|
||||
base64.b64decode(data.get("content", "")).decode()
|
||||
if data.get("content")
|
||||
else ""
|
||||
)
|
||||
|
||||
def list_unread_notifications(
|
||||
self, since: Optional[str] = None
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
url = f"{self.base_url}/api/v1/notifications"
|
||||
params: dict[str, str] = {"all": "false"}
|
||||
if since:
|
||||
params["since"] = since
|
||||
response = self.client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
notifications: list[dict[str, Any]] = response.json()
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for n in notifications:
|
||||
repo_info = n.get("repository") or {}
|
||||
owner_info = repo_info.get("owner") or {}
|
||||
owner_login = owner_info.get("login", "")
|
||||
if owner_login == GITEA_ORG_FILTER:
|
||||
result.append(n)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing unread notifications: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def mark_notification_as_read(self, thread_id: int) -> bool:
|
||||
try:
|
||||
url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}"
|
||||
response = self.client.patch(url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error marking notification thread {thread_id} as read: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
def merge_pull_request(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pull_number: int,
|
||||
style: str = "squash",
|
||||
title: str = "",
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
try:
|
||||
url = (
|
||||
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/merge"
|
||||
)
|
||||
data: dict[str, Any] = {
|
||||
"Do": style,
|
||||
"MergeTitleField": title,
|
||||
"MergeMessageField": message,
|
||||
}
|
||||
response = self.client.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error merging pull request {pull_number}: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user