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:
meeks
2026-07-17 07:31:05 +02:00
parent 3c94c3cfac
commit 25473ed684
21 changed files with 1480 additions and 728 deletions
+35
View File
@@ -0,0 +1,35 @@
"""Gitea API client package."""
from .client import GiteaClient
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
from .models import (
CommentModel,
GiteaConfig,
IssueModel,
LabelModel,
PullRequestFileModel,
PullRequestModel,
RepositoryModel,
UserModel,
)
__all__ = [
"FilesClient",
"GiteaClient",
"IssuesClient",
"NotificationsClient",
"PullRequestsClient",
"ReposClient",
"CommentModel",
"GiteaConfig",
"IssueModel",
"LabelModel",
"PullRequestFileModel",
"PullRequestModel",
"RepositoryModel",
"UserModel",
]
+35 -469
View File
@@ -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
+116
View File
@@ -0,0 +1,116 @@
"""Files client for Gitea API operations."""
import base64
import logging
from typing import Any
import httpx
logger: logging.Logger = logging.getLogger("gitea.files_client")
class FilesClient:
"""HTTP client for Gitea Files and Git Refs API operations."""
def __init__(self, base_url: str, client: httpx.Client) -> None:
"""Initialize the FilesClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> dict[str, Any]:
"""Update a file in a repository.
Args:
owner: Repository owner.
repo: Repository name.
path: File path.
message: Commit message.
content: File content.
branch: Branch name.
Returns:
The API response.
"""
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]:
"""Get the content of a file or directory.
Args:
owner: Repository owner.
repo: Repository name.
path: File or directory path.
ref: Git reference (branch, tag, commit).
Returns:
File content as string, or list of file names if path is a directory.
"""
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 update_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
"""Update a git reference.
Args:
owner: Repository owner.
repo: Repository name.
ref: Reference name (e.g., heads/main).
sha: New SHA for the reference.
Returns:
The API response.
"""
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]:
"""Create a new git reference.
Args:
owner: Repository owner.
repo: Repository name.
ref: Reference name (e.g., refs/heads/new-branch).
sha: SHA for the reference.
Returns:
The API response.
"""
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()
+248
View File
@@ -0,0 +1,248 @@
"""Issues client for Gitea API operations."""
import logging
from typing import Any, Callable, Optional
import httpx
from .models import (
CommentModel,
IssueModel,
LabelModel,
RepositoryModel,
UserModel,
)
logger: logging.Logger = logging.getLogger("gitea.issues_client")
class IssuesClient:
"""HTTP client for Gitea Issues API operations."""
def __init__(
self,
base_url: str,
client: httpx.Client,
get_user: Callable[[], UserModel] | None = None,
get_repos: Callable[[], list[RepositoryModel]] | None = None,
) -> None:
"""Initialize the IssuesClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
get_user: Optional callable to get the authenticated user.
get_repos: Optional callable to get all user repos.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self._get_user: Callable[[], UserModel] | None = get_user
self._get_repos: Callable[[], list[RepositoryModel]] | None = get_repos
def list_repo_issues(
self, owner: str, repo: str, state: str = "open"
) -> list[IssueModel]:
"""List issues for a repository.
Args:
owner: Repository owner.
repo: Repository name.
state: Issue state filter (open, closed, all).
Returns:
List of issues matching the criteria.
"""
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 get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
"""Get a specific issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
The requested issue.
"""
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:
"""Close an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
The updated issue.
"""
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 get_issue_comments(
self, owner: str, repo: str, issue_number: int
) -> list[CommentModel]:
"""Get comments on an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
Returns:
List of comments on the issue.
"""
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 list_assigned_issues(self, owner: str = "", repo: str = "") -> list[IssueModel]:
"""List issues assigned to the authenticated user.
Args:
owner: Optional repository owner to filter by.
repo: Optional repository name to filter by.
Returns:
List of issues assigned to the authenticated user.
"""
try:
if self._get_user is None or self._get_repos is None:
logger.error("get_user and get_repos callables are required")
return []
user = self._get_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._get_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 assign_issue(
self, owner: str, repo: str, issue_number: int, username: str
) -> IssueModel:
"""Assign an issue to a user.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
username: Username to assign.
Returns:
The updated issue.
"""
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:
"""Create a new issue.
Args:
owner: Repository owner.
repo: Repository name.
title: Issue title.
body: Issue body/description.
labels: Optional list of label IDs.
assignees: Optional list of usernames to assign.
Returns:
The created issue.
"""
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:
"""Add a comment to an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
body: Comment body.
Returns:
The created comment.
"""
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:
"""Add a label to an issue.
Args:
owner: Repository owner.
repo: Repository name.
issue_number: Issue number.
label: Label name or ID.
Returns:
The added label.
"""
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())
+78
View File
@@ -0,0 +1,78 @@
"""Notifications client for Gitea API operations."""
import logging
from typing import Any, Optional
import httpx
logger: logging.Logger = logging.getLogger("gitea.notifications_client")
class NotificationsClient:
"""HTTP client for Gitea Notifications API operations."""
def __init__(self, base_url: str, client: httpx.Client, org_filter: str) -> None:
"""Initialize the NotificationsClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
org_filter: Organization filter for notifications.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self.org_filter: str = org_filter
def list_unread_notifications(
self, since: Optional[str] = None
) -> list[dict[str, Any]]:
"""List unread notifications.
Args:
since: Optional ISO 8601 timestamp to filter notifications after.
Returns:
List of unread notifications for the configured organization.
"""
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 == self.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:
"""Mark a notification as read.
Args:
thread_id: Notification thread ID.
Returns:
True if successful, False otherwise.
"""
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
+462
View File
@@ -0,0 +1,462 @@
"""Pull Requests client for Gitea API operations."""
import logging
from typing import Any, Callable
import httpx
from .models import (
CommentModel,
LabelModel,
PullRequestFileModel,
PullRequestModel,
RepositoryModel,
UserModel,
)
logger: logging.Logger = logging.getLogger("gitea.prs_client")
class PullRequestsClient:
"""HTTP client for Gitea Pull Requests API operations."""
def __init__(
self,
base_url: str,
client: httpx.Client,
get_user: Callable[[], UserModel] | None = None,
get_repos: Callable[[], list[RepositoryModel]] | None = None,
) -> None:
"""Initialize the PullRequestsClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
get_user: Optional callable to get the authenticated user.
get_repos: Optional callable to get all user repos.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self._get_user: Callable[[], UserModel] | None = get_user
self._get_repos: Callable[[], list[RepositoryModel]] | None = get_repos
def list_repo_pull_requests(
self, owner: str, repo: str, state: str = "open"
) -> list[PullRequestModel]:
"""List pull requests for a repository.
Args:
owner: Repository owner.
repo: Repository name.
state: PR state filter (open, closed, all).
Returns:
List of pull requests matching the criteria.
"""
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:
"""Get a specific pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The requested pull request.
"""
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 close_pull_request(
self, owner: str, repo: str, pull_number: int
) -> PullRequestModel:
"""Close a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The updated pull request.
"""
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_pull_request_comments(
self, owner: str, repo: str, pull_number: int
) -> list[CommentModel]:
"""Get comments on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
List of comments on the pull request.
"""
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:
"""Get the diff for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The diff as a string.
"""
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:
"""Get the patch for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
The patch as a string.
"""
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]:
"""Get the files changed in a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
Returns:
List of files changed in the pull request.
"""
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_pull_requests(
self, owner: str = "", repo: str = ""
) -> list[PullRequestModel]:
"""List all pull requests assigned to or authored by the authenticated user.
Args:
owner: Optional repository owner to filter by.
repo: Optional repository name to filter by.
Returns:
List of pull requests assigned to or authored by the user.
"""
try:
if self._get_user is None or self._get_repos is None:
logger.error("get_user and get_repos callables are required")
return []
user = self._get_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._get_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:
"""Create a new pull request.
Args:
owner: Repository owner.
repo: Repository name.
head: Head branch name.
base: Base branch name.
title: Pull request title.
description: Pull request description.
Returns:
The created pull request.
"""
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:
"""Update a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
title: Optional new title.
body: Optional new body.
state: Optional new state.
Returns:
The updated pull request.
"""
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:
"""Create a pull request (alias for create_pull_request).
Args:
owner: Repository owner.
repo: Repository name.
title: Pull request title.
description: Pull request description.
head: Head branch name.
base: Base branch name.
Returns:
The created pull request.
"""
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]:
"""Approve a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
comment: Review comment.
Returns:
The review response.
"""
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]:
"""Request changes on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
comment: Review comment.
Returns:
The review response.
"""
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]]:
"""Get reviews for a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
Returns:
List of reviews.
"""
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]:
"""Dismiss a review on a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
review_id: Review ID to dismiss.
message: Dismissal message.
Returns:
The dismissal response.
"""
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 add_label_pr(
self, owner: str, repo: str, pr_number: int, label: str
) -> LabelModel:
"""Add a label to a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
label: Label name or ID.
Returns:
The added label.
"""
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 merge_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
style: str = "squash",
title: str = "",
message: str = "",
) -> bool:
"""Merge a pull request.
Args:
owner: Repository owner.
repo: Repository name.
pull_number: Pull request number.
style: Merge style (squash, merge, rebase).
title: Optional merge commit title.
message: Optional merge commit message.
Returns:
True if merge was successful.
"""
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
+72
View File
@@ -0,0 +1,72 @@
"""Repositories client for Gitea API operations."""
import logging
from typing import Any
import httpx
from .models import RepositoryModel, UserModel
logger: logging.Logger = logging.getLogger("gitea.repos_client")
class ReposClient:
"""HTTP client for Gitea Repositories API operations."""
def __init__(self, base_url: str, client: httpx.Client, org_filter: str) -> None:
"""Initialize the ReposClient.
Args:
base_url: The base URL for the Gitea API.
client: The httpx client for making requests.
org_filter: Organization filter for repositories.
"""
self.base_url: str = base_url
self.client: httpx.Client = client
self.org_filter: str = org_filter
def list_all_user_repos(self) -> list[RepositoryModel]:
"""List all repositories for the authenticated user.
Returns:
List of repositories belonging to the configured organization.
"""
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") == self.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 get_authenticated_user(self) -> UserModel:
"""Get the authenticated user.
Returns:
The authenticated user.
Raises:
RuntimeError: If the user cannot be retrieved.
"""
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
+10 -6
View File
@@ -50,7 +50,7 @@ class FileTools:
limit: Maximum number of lines to return (default 250).
"""
try:
content = self._client.get_file_content(owner, repo, path)
content = self._client.files.get_file_content(owner, repo, path)
raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit)
except Exception as e:
@@ -73,22 +73,26 @@ class FileTools:
limit: Maximum number of lines to return (default 250).
"""
try:
content = self._client.get_file_content(owner, repo, path, ref)
content = self._client.files.get_file_content(owner, repo, path, ref)
raw: str = "\n".join(content) if isinstance(content, list) else content
return self._paginate_lines(raw, offset, limit)
except Exception as e:
return f"Error getting file content: {str(e)}"
def commit_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
def commit_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> str:
try:
self._client.update_file(owner, repo, path, message, content, branch)
self._client.files.update_file(owner, repo, path, message, content, branch)
return f"File '{path}' committed successfully to {owner}/{repo}."
except Exception as e:
return f"Error committing file: {str(e)}"
def update_file(self, owner: str, repo: str, path: str, message: str, content: str, branch: str) -> str:
def update_file(
self, owner: str, repo: str, path: str, message: str, content: str, branch: str
) -> str:
try:
self._client.update_file(owner, repo, path, message, content, branch)
self._client.files.update_file(owner, repo, path, message, content, branch)
return f"File '{path}' updated in {owner}/{repo}."
except Exception as e:
return f"Error updating file: {str(e)}"
+1 -1
View File
@@ -10,7 +10,7 @@ class GitTools:
def create_branch(self, owner: str, repo: str, ref: str, sha: str) -> str:
try:
self._client.create_ref(owner, repo, ref, sha)
self._client.files.create_ref(owner, repo, ref, sha)
return f"Branch '{ref}' created successfully in {owner}/{repo}."
except Exception as e:
return f"Error creating branch: {str(e)}"
+9 -9
View File
@@ -17,14 +17,14 @@ class IssueTools:
def get_issue(self, owner: str, repo: str, issue_number: int) -> str:
try:
issue: IssueModel = self._client.get_issue(owner, repo, issue_number)
issue: IssueModel = self._client.issues.get_issue(owner, repo, issue_number)
return issue.model_dump_json(indent=2)
except Exception as e:
return f"Error getting issue: {str(e)}"
def close_issue(self, owner: str, repo: str, issue_number: int) -> str:
try:
self._client.close_issue(owner, repo, issue_number)
self._client.issues.close_issue(owner, repo, issue_number)
return f"Issue #{issue_number} closed successfully."
except Exception as e:
return f"Error closing issue: {str(e)}"
@@ -44,7 +44,7 @@ class IssueTools:
offset: Zero-based comment index to start from (default 0).
"""
try:
comments: list[CommentModel] = self._client.get_issue_comments(
comments: list[CommentModel] = self._client.issues.get_issue_comments(
owner, repo, issue_number
)
total: int = len(comments)
@@ -62,12 +62,12 @@ class IssueTools:
def list_assigned_issues(self) -> list[dict[str, Any]]:
try:
repos = self._client.list_all_user_repos()
repos = self._client.repos.list_all_user_repos()
all_issues: list[dict[str, Any]] = []
for repo in repos:
owner = repo.owner
repo_name = repo.name
issues = self._client.list_assigned_issues(owner, repo_name)
issues = self._client.issues.list_assigned_issues(owner, repo_name)
if issues:
all_issues.extend(
[
@@ -84,7 +84,7 @@ class IssueTools:
def list_issues(self, owner: str, repo: str, state: str = "open") -> str:
try:
issues = self._client.list_repo_issues(owner, repo, state)
issues = self._client.issues.list_repo_issues(owner, repo, state)
if not issues:
return f"No issues in {owner}/{repo}."
summary = [f"#{issue.number}: {issue.title}" for issue in issues]
@@ -102,7 +102,7 @@ class IssueTools:
assignees: list[str] | None = None,
) -> str:
try:
issue = self._client.create_issue(
issue = self._client.issues.create_issue(
owner, repo, title, body, labels, assignees
)
return f"Issue #{issue.number} created successfully in {owner}/{repo}."
@@ -113,7 +113,7 @@ class IssueTools:
self, owner: str, repo: str, issue_number: int, label: str
) -> str:
try:
self._client.add_label(owner, repo, issue_number, label)
self._client.issues.add_label(owner, repo, issue_number, label)
return f"Label '{label}' added to issue #{issue_number}."
except Exception as e:
return f"Error adding label to issue #{issue_number}: {e}"
@@ -122,7 +122,7 @@ class IssueTools:
self, owner: str, repo: str, issue_number: int, body: str
) -> str:
try:
self._client.add_comment(owner, repo, issue_number, body)
self._client.issues.add_comment(owner, repo, issue_number, body)
return f"Comment added to issue #{issue_number}."
except Exception as e:
return f"Error adding comment to issue #{issue_number}: {e}"
+44 -17
View File
@@ -42,14 +42,16 @@ class PRTools:
def get_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
try:
pr: PullRequestModel = self._client.get_pull_request(owner, repo, pull_number)
pr: PullRequestModel = self._client.prs.get_pull_request(
owner, repo, pull_number
)
return pr.model_dump_json(indent=2)
except Exception as e:
return f"Error getting pull request: {str(e)}"
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> str:
try:
self._client.close_pull_request(owner, repo, pull_number)
self._client.prs.close_pull_request(owner, repo, pull_number)
return f"Pull request #{pull_number} closed successfully."
except Exception as e:
return f"Error closing pull request: {str(e)}"
@@ -69,7 +71,7 @@ class PRTools:
offset: Zero-based comment index to start from (default 0).
"""
try:
comments: list[CommentModel] = self._client.get_pull_request_comments(
comments: list[CommentModel] = self._client.prs.get_pull_request_comments(
owner, repo, pull_number
)
total: int = len(comments)
@@ -87,14 +89,21 @@ class PRTools:
def list_assigned_pull_requests(self) -> list[dict[str, Any]]:
try:
repos = self._client.list_all_user_repos()
repos = self._client.repos.list_all_user_repos()
all_prs: list[dict[str, Any]] = []
for repo_info in repos:
repo_owner = repo_info.owner
repo_name = repo_info.name
prs = self._client.list_assigned_pull_requests(repo_owner, repo_name)
prs = self._client.prs.list_assigned_pull_requests(
repo_owner, repo_name
)
if prs:
all_prs.extend([pr.model_dump() if hasattr(pr, 'model_dump') else pr for pr in prs])
all_prs.extend(
[
pr.model_dump() if hasattr(pr, "model_dump") else pr
for pr in prs
]
)
return all_prs
except Exception as e:
logger.error(f"Error listing assigned pull requests: {e}", exc_info=True)
@@ -102,7 +111,7 @@ class PRTools:
def list_pull_requests(self, owner: str, repo: str, state: str = "open") -> str:
try:
prs = self._client.list_repo_pull_requests(owner, repo, state)
prs = self._client.prs.list_repo_pull_requests(owner, repo, state)
if not prs:
return f"No PRs in {owner}/{repo}."
summary = [f"#{pr.number}: {pr.title}" for pr in prs]
@@ -110,9 +119,19 @@ class PRTools:
except Exception as e:
return f"Error listing PRs: {str(e)}"
def create_pull_request(self, owner: str, repo: str, head: str, base: str, title: str, description: str = "") -> str:
def create_pull_request(
self,
owner: str,
repo: str,
head: str,
base: str,
title: str,
description: str = "",
) -> str:
try:
pr = self._client.create_pr_via_tea(owner, repo, title, description, head, base)
pr = self._client.prs.create_pr_via_tea(
owner, repo, title, description, head, base
)
return pr.model_dump_json(indent=2)
except Exception as e:
return f"Error creating PR: {str(e)}"
@@ -127,14 +146,16 @@ class PRTools:
state: str | None = None,
) -> str:
try:
pr = self._client.update_pull_request(owner, repo, pull_number, title, body, state)
pr = self._client.prs.update_pull_request(
owner, repo, pull_number, title, body, state
)
return pr.model_dump_json(indent=2)
except Exception as e:
return f"Error updating PR #{pull_number}: {str(e)}"
def add_label_to_pr(self, owner: str, repo: str, pr_number: int, label: str) -> str:
try:
self._client.add_label_pr(owner, repo, pr_number, label)
self._client.prs.add_label_pr(owner, repo, pr_number, label)
return f"Label '{label}' added to PR #{pr_number}."
except Exception as e:
return f"Error adding label to PR #{pr_number}: {e}"
@@ -155,7 +176,7 @@ class PRTools:
Increment by max_chars to page through a large diff.
"""
try:
diff: str = self._client.get_pull_request_diff(owner, repo, pull_number)
diff: str = self._client.prs.get_pull_request_diff(owner, repo, pull_number)
return _truncate_diff(diff, max_chars, char_offset)
except Exception as e:
return f"Error getting PR diff: {str(e)}"
@@ -176,21 +197,27 @@ class PRTools:
Increment by max_chars to page through a large patch.
"""
try:
patch: str = self._client.get_pull_request_patch(owner, repo, pull_number)
patch: str = self._client.prs.get_pull_request_patch(
owner, repo, pull_number
)
return _truncate_diff(patch, max_chars, char_offset)
except Exception as e:
return f"Error getting PR patch: {str(e)}"
def approve_pull_request(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
def approve_pull_request(
self, owner: str, repo: str, pull_number: int, comment: str
) -> str:
try:
self._client.approve_pr(owner, repo, pull_number, comment)
self._client.prs.approve_pr(owner, repo, pull_number, comment)
return f"Approved PR #{pull_number}."
except Exception as e:
return f"Error approving PR: {str(e)}"
def request_changes(self, owner: str, repo: str, pull_number: int, comment: str) -> str:
def request_changes(
self, owner: str, repo: str, pull_number: int, comment: str
) -> str:
try:
self._client.request_changes_pr(owner, repo, pull_number, comment)
self._client.prs.request_changes_pr(owner, repo, pull_number, comment)
return f"Requested changes on PR #{pull_number}."
except Exception as e:
return f"Error requesting changes: {str(e)}"
+64 -21
View File
@@ -21,7 +21,7 @@ class WorkspaceManager:
def _configure_repo_user(self, repo_path: Path) -> None:
try:
client = GiteaClient()
user = client.get_authenticated_user()
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
@@ -32,19 +32,29 @@ class WorkspaceManager:
# Configure extraHeader locally for the repo
subprocess.run(
["git", "-C", str(repo_path), "config", "http.extraHeader", f"Authorization: Basic {auth_b64}"],
check=True, capture_output=True
[
"git",
"-C",
str(repo_path),
"config",
"http.extraHeader",
f"Authorization: Basic {auth_b64}",
],
check=True,
capture_output=True,
)
name: str = user.full_name or user.login
email: str = user.email or f"{user.login}@noreply.gitea"
subprocess.run(
["git", "-C", str(repo_path), "config", "user.name", name],
check=True, capture_output=True
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "config", "user.email", email],
check=True, capture_output=True
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error configuring local git user: {e}")
@@ -64,59 +74,84 @@ class WorkspaceManager:
auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run(
["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url],
check=True, capture_output=True,
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
# Check for any uncommitted changes or untracked files
status_res = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
check=True, capture_output=True, text=True
check=True,
capture_output=True,
text=True,
)
if status_res.stdout.strip():
logger.info(f"Uncommitted changes detected in {repo_path}. Stashing before sanitization.")
logger.info(
f"Uncommitted changes detected in {repo_path}. Stashing before sanitization."
)
subprocess.run(
["git", "-C", str(repo_path), "stash", "push", "-u", "-m", "Auto-backup before agent sanitization"],
check=True, capture_output=True
[
"git",
"-C",
str(repo_path),
"stash",
"push",
"-u",
"-m",
"Auto-backup before agent sanitization",
],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "clean", "-fdx"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "main"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "master"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "main"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "master"],
check=True, capture_output=True,
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error during sanitization: {e}", exc_info=True)
raise RuntimeError(f"Failed to sanitize repository {repo_full_name} at {repo_path}: {e}") from e
raise RuntimeError(
f"Failed to sanitize repository {repo_full_name} at {repo_path}: {e}"
) from e
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
repo_path: Path = self.get_repo_path(repo_full_name)
if repo_path.exists():
if not (repo_path / ".git").exists():
new_path: Path = repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
new_path: Path = (
repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
)
if new_path.exists():
shutil.rmtree(new_path)
repo_path.rename(new_path)
@@ -126,7 +161,7 @@ class WorkspaceManager:
auth_url = self._get_authenticated_url(repo_full_name)
client = GiteaClient()
user = client.get_authenticated_user()
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
@@ -135,8 +170,16 @@ class WorkspaceManager:
auth_bytes: bytes = auth_str.encode("utf-8")
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
subprocess.run(
["git", "clone", "-c", f"http.extraHeader=Authorization: Basic {auth_b64}", auth_url, str(repo_path)],
check=True, capture_output=True
[
"git",
"clone",
"-c",
f"http.extraHeader=Authorization: Basic {auth_b64}",
auth_url,
str(repo_path),
],
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
return repo_path