Files
coding-agent-gitea/gitea/client.py
T
2026-06-30 22:01:32 +02:00

451 lines
20 KiB
Python

import httpx
import json
import base64
from typing import Any, Optional
from .config import GITEA_URL, GITEA_TOKEN
from .models import (
UserModel,
LabelModel,
RepositoryModel,
IssueModel,
PullRequestModel,
CommentModel,
PullRequestFileModel,
)
from core.interfaces import (
IssuesClient,
PullRequestsClient,
FilesClient,
RefsClient,
ReposClient,
)
class GiteaClient(IssuesClient, PullRequestsClient, FilesClient, RefsClient, ReposClient):
"""HTTP client for Gitea API v1."""
def __init__(self) -> None:
self.base_url: str = GITEA_URL.rstrip("/")
self.headers: dict[str, str] = {
"Authorization": f"token {GITEA_TOKEN}",
"Accept": "application/json",
}
def get_authenticated_user(self) -> UserModel | None:
try:
with httpx.Client() as client:
response = client.get(f"{self.base_url}/api/v1/user", headers=self.headers)
response.raise_for_status()
return UserModel(**response.json())
except Exception as e:
print(f"Error getting authenticated user: {e}")
return None
def list_all_user_repos(self) -> list[RepositoryModel]:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/user/repos"
response = client.get(url, headers=self.headers)
response.raise_for_status()
repos: list[dict[str, Any]] = response.json()
# Filter to ONLY meeks 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") == "meeks":
seen.add(full_name)
result.append(RepositoryModel(**r))
return result
except Exception as e:
print(f"Error listing user repos: {e}")
return []
def list_repo_issues(self, owner: str, repo: str, state: str = "open") -> list[IssueModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?type=issues&state={state}"
response = client.get(url, headers=self.headers)
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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}"
response = client.get(url, headers=self.headers)
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:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return PullRequestModel(**response.json())
def get_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return IssueModel(**response.json())
def close_issue(self, owner: str, repo: str, issue_number: int) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, str] = {"state": "closed"}
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def close_pull_request(self, owner: str, repo: str, pull_number: int) -> PullRequestModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}"
data: dict[str, str] = {"state": "closed"}
response = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
def get_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[CommentModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
response = client.get(url, headers=self.headers)
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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pull_number}/comments"
response = client.get(url, headers=self.headers)
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:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/diff"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return response.text
def get_pull_request_patch(self, owner: str, repo: str, pull_number: int) -> str:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/patch"
response = client.get(url, headers=self.headers)
response.raise_for_status()
return response.text
def get_pull_request_files(self, owner: str, repo: str, pull_number: int) -> list[PullRequestFileModel]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pull_number}/files"
response = client.get(url, headers=self.headers)
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 = httpx.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues?assignee={username}&state=open&type=issues",
headers=self.headers,
)
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 if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "")
repo_name = r.name if hasattr(r, 'name') else r.get("name", "")
resp = httpx.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/issues?assignee={username}&state=open&type=issues",
headers=self.headers,
)
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:
print(f"DEBUG: list_assigned_issues error: {e}")
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 = httpx.get(
f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open",
headers=self.headers,
)
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 if hasattr(r, 'owner') else (r.get("owner") or {}).get("login", "")
repo_name = r.name if hasattr(r, 'name') else r.get("name", "")
resp = httpx.get(
f"{self.base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls?state=open",
headers=self.headers,
)
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:
print(f"DEBUG: list_assigned_pull_requests error: {e}")
return []
def create_pull_request(
self, owner: str, repo: str, head: str, base: str, title: str, description: str = ""
) -> PullRequestModel:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls"
data: dict[str, str] = {
"title": title,
"body": description,
"head": head,
"base": base,
}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
print(f"Error creating pull request: {e}")
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:
with httpx.Client() as client:
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 = client.patch(url, headers=self.headers, json=data)
response.raise_for_status()
return PullRequestModel(**response.json())
except Exception as e:
print(f"Error updating pull request: {e}")
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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "APPROVED", "body": comment}
response = client.post(url, headers=self.headers, 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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
data: dict[str, Any] = {"event": "REQUEST_CHANGES", "body": comment}
response = client.post(url, headers=self.headers, 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]]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
response = client.get(url, headers=self.headers)
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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
data: dict[str, str] = {"message": message}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def assign_issue(self, owner: str, repo: str, issue_number: int, username: str) -> IssueModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
data: dict[str, list[str]] = {"assignees": [username]}
response = client.patch(url, headers=self.headers, 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:
with httpx.Client() as client:
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 = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return IssueModel(**response.json())
def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> CommentModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
data: dict[str, str] = {"body": body}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return CommentModel(**response.json())
def add_label(self, owner: str, repo: str, issue_number: int, label: str) -> LabelModel:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/labels"
data: list[str] = [label]
response = client.post(url, headers=self.headers, 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:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/issues/{pr_number}/labels"
data: list[str] = [label]
response = client.post(url, headers=self.headers, 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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/ref/{ref}"
data: dict[str, str] = {"sha": sha}
response = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return response.json()
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> dict[str, Any]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/git/refs"
data: dict[str, str] = {"ref": ref, "sha": sha}
response = client.post(url, headers=self.headers, 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]:
with httpx.Client() as client:
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 = client.put(url, headers=self.headers, 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]:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{path}"
params: dict[str, str] = {"ref": ref}
response = client.get(url, headers=self.headers, 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:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/notifications?all=false"
if since:
url += f"&since={since}"
response = client.get(url, headers=self.headers)
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 == "meeks":
result.append(n)
return result
except Exception as e:
print(f"Error listing unread notifications: {e}")
return []
def mark_notification_as_read(self, thread_id: int) -> bool:
try:
with httpx.Client() as client:
url = f"{self.base_url}/api/v1/notifications/threads/{thread_id}"
response = client.patch(url, headers=self.headers)
response.raise_for_status()
return True
except Exception as e:
print(f"Error marking notification thread {thread_id} as read: {e}")
return False
def merge_pull_request(
self, owner: str, repo: str, pull_number: int, style: str = "squash", title: str = "", message: str = ""
) -> bool:
try:
with httpx.Client() as client:
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 = client.post(url, headers=self.headers, json=data)
response.raise_for_status()
return True
except Exception as e:
print(f"Error merging pull request {pull_number}: {e}")
raise