Files
meeks ae4e2d46ac refactor: replace Any types with specific types and update bad_code.md
- Replace Any with object or specific types across codebase
- Add ReviewRequest dataclass for PR review payloads
- Update bad_code.md: mark 5.1 (Any Type Overuse) as resolved
- Fix summary table with accurate counts and unresolved issues list
2026-07-19 15:35:17 +02:00

470 lines
15 KiB
Python

"""Pull Requests client for Gitea API operations."""
import logging
from dataclasses import dataclass
from typing import Callable
import httpx
from .models import (
CommentModel,
LabelModel,
PullRequestFileModel,
PullRequestModel,
RepositoryModel,
UserModel,
)
logger: logging.Logger = logging.getLogger("gitea.prs_client")
@dataclass
class ReviewRequest:
event: str
body: str
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, str | None] = {}
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, object]:
"""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"
review: ReviewRequest = ReviewRequest(event="APPROVED", body=comment)
response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status()
return response.json()
def request_changes_pr(
self, owner: str, repo: str, pr_number: int, comment: str
) -> dict[str, object]:
"""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"
review: ReviewRequest = ReviewRequest(event="REQUEST_CHANGES", body=comment)
response = self.client.post(url, json={"event": review.event, "body": review.body})
response.raise_for_status()
return response.json()
def get_pr_reviews(
self, owner: str, repo: str, pr_number: int
) -> list[dict[str, object]]:
"""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, object]:
"""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, str] = {
"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