feat: harden gateway with policy engine, secure tools, and governance docs
This commit is contained in:
+412
-157
@@ -1,8 +1,9 @@
|
||||
"""Gitea API client with bot user authentication."""
|
||||
"""Gitea API client with hardened request handling."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
@@ -12,47 +13,37 @@ from aegis_gitea_mcp.config import get_settings
|
||||
class GiteaError(Exception):
|
||||
"""Base exception for Gitea API errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthenticationError(GiteaError):
|
||||
"""Raised when authentication with Gitea fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthorizationError(GiteaError):
|
||||
"""Raised when bot user lacks permission for an operation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaNotFoundError(GiteaError):
|
||||
"""Raised when a requested resource is not found."""
|
||||
|
||||
pass
|
||||
"""Raised when requested resource is not found."""
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for interacting with Gitea API as a bot user."""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None) -> None:
|
||||
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
|
||||
"""Initialize Gitea client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of Gitea instance (defaults to config value)
|
||||
token: Bot user access token (defaults to config value)
|
||||
base_url: Optional base URL override.
|
||||
token: Optional token override.
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
|
||||
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
|
||||
self.token = token or self.settings.gitea_token
|
||||
self.client: AsyncClient | None = None
|
||||
|
||||
self.client: Optional[AsyncClient] = None
|
||||
|
||||
async def __aenter__(self) -> "GiteaClient":
|
||||
"""Async context manager entry."""
|
||||
async def __aenter__(self) -> GiteaClient:
|
||||
"""Create async HTTP client context."""
|
||||
self.client = AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={
|
||||
@@ -65,26 +56,22 @@ class GiteaClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
"""Close async HTTP client context."""
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
|
||||
def _handle_response(self, response: Response, correlation_id: str) -> Any:
|
||||
"""Handle Gitea API response and raise appropriate exceptions.
|
||||
|
||||
Args:
|
||||
response: HTTP response from Gitea
|
||||
correlation_id: Correlation ID for audit logging
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
def _ensure_client(self) -> AsyncClient:
|
||||
"""Return initialized HTTP client.
|
||||
|
||||
Raises:
|
||||
GiteaAuthenticationError: On 401 responses
|
||||
GiteaAuthorizationError: On 403 responses
|
||||
GiteaNotFoundError: On 404 responses
|
||||
GiteaError: On other error responses
|
||||
RuntimeError: If called outside async context manager.
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
return self.client
|
||||
|
||||
def _handle_response(self, response: Response, correlation_id: str) -> Any:
|
||||
"""Handle HTTP response and map to domain exceptions."""
|
||||
if response.status_code == 401:
|
||||
self.audit.log_security_event(
|
||||
event_type="authentication_failure",
|
||||
@@ -97,7 +84,7 @@ class GiteaClient:
|
||||
if response.status_code == 403:
|
||||
self.audit.log_access_denied(
|
||||
tool_name="gitea_api",
|
||||
reason="Bot user lacks permission",
|
||||
reason="bot user lacks permission",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
|
||||
@@ -109,7 +96,9 @@ class GiteaClient:
|
||||
error_msg = f"Gitea API error: {response.status_code}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = f"{error_msg} - {error_data.get('message', '')}"
|
||||
message = error_data.get("message") if isinstance(error_data, dict) else None
|
||||
if message:
|
||||
error_msg = f"{error_msg} - {message}"
|
||||
except Exception:
|
||||
pass
|
||||
raise GiteaError(error_msg)
|
||||
@@ -119,35 +108,34 @@ class GiteaClient:
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
async def get_current_user(self) -> Dict[str, Any]:
|
||||
"""Get information about the current bot user.
|
||||
|
||||
Returns:
|
||||
User information dict
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
correlation_id: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Execute a request to Gitea API with shared error handling."""
|
||||
client = self._ensure_client()
|
||||
response = await client.request(method=method, url=endpoint, params=params, json=json_body)
|
||||
return self._handle_response(response, correlation_id)
|
||||
|
||||
async def get_current_user(self) -> dict[str, Any]:
|
||||
"""Get current bot user profile."""
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get("/api/v1/user")
|
||||
user_data = self._handle_response(response, correlation_id)
|
||||
|
||||
result = await self._request("GET", "/api/v1/user", correlation_id=correlation_id)
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
return user_data
|
||||
|
||||
return result if isinstance(result, dict) else {}
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_current_user",
|
||||
@@ -157,39 +145,22 @@ class GiteaClient:
|
||||
)
|
||||
raise
|
||||
|
||||
async def list_repositories(self) -> List[Dict[str, Any]]:
|
||||
"""List all repositories visible to the bot user.
|
||||
|
||||
Returns:
|
||||
List of repository information dicts
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
async def list_repositories(self) -> list[dict[str, Any]]:
|
||||
"""List all repositories visible to the bot user."""
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get("/api/v1/user/repos")
|
||||
repos_data = self._handle_response(response, correlation_id)
|
||||
|
||||
# Ensure we have a list
|
||||
repos = repos_data if isinstance(repos_data, list) else []
|
||||
|
||||
result = await self._request("GET", "/api/v1/user/repos", correlation_id=correlation_id)
|
||||
repositories = result if isinstance(result, list) else []
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
params={"count": len(repos)},
|
||||
params={"count": len(repositories)},
|
||||
)
|
||||
|
||||
return repos
|
||||
|
||||
return repositories
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="list_repositories",
|
||||
@@ -199,43 +170,27 @@ class GiteaClient:
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_repository(self, owner: str, repo: str) -> Dict[str, Any]:
|
||||
"""Get information about a specific repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Repository information dict
|
||||
|
||||
Raises:
|
||||
GiteaNotFoundError: If repository doesn't exist or bot lacks access
|
||||
GiteaError: On other API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
async def get_repository(self, owner: str, repo: str) -> dict[str, Any]:
|
||||
"""Get repository metadata."""
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
repository=repo_id,
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(f"/api/v1/repos/{owner}/{repo}")
|
||||
repo_data = self._handle_response(response, correlation_id)
|
||||
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
|
||||
return repo_data
|
||||
|
||||
return result if isinstance(result, dict) else {}
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_repository",
|
||||
@@ -247,26 +202,13 @@ class GiteaClient:
|
||||
raise
|
||||
|
||||
async def get_file_contents(
|
||||
self, owner: str, repo: str, filepath: str, ref: str = "main"
|
||||
) -> Dict[str, Any]:
|
||||
"""Get contents of a file in a repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
filepath: Path to file within repository
|
||||
ref: Branch, tag, or commit ref (defaults to 'main')
|
||||
|
||||
Returns:
|
||||
File contents dict with 'content', 'encoding', 'size', etc.
|
||||
|
||||
Raises:
|
||||
GiteaNotFoundError: If file doesn't exist
|
||||
GiteaError: On other API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
filepath: str,
|
||||
ref: str = "main",
|
||||
) -> dict[str, Any]:
|
||||
"""Get file contents from a repository."""
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_file_contents",
|
||||
@@ -275,20 +217,22 @@ class GiteaClient:
|
||||
params={"ref": ref},
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/contents/{filepath}",
|
||||
params={"ref": ref},
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
file_data = self._handle_response(response, correlation_id)
|
||||
|
||||
# Check file size against limit
|
||||
file_size = file_data.get("size", 0)
|
||||
if not isinstance(result, dict):
|
||||
raise GiteaError("Unexpected response type for file contents")
|
||||
|
||||
file_size = int(result.get("size", 0))
|
||||
if file_size > self.settings.max_file_size_bytes:
|
||||
error_msg = (
|
||||
f"File size ({file_size} bytes) exceeds "
|
||||
f"limit ({self.settings.max_file_size_bytes} bytes)"
|
||||
f"File size ({file_size} bytes) exceeds limit "
|
||||
f"({self.settings.max_file_size_bytes} bytes)"
|
||||
)
|
||||
self.audit.log_security_event(
|
||||
event_type="file_size_limit_exceeded",
|
||||
@@ -311,9 +255,7 @@ class GiteaClient:
|
||||
result_status="success",
|
||||
params={"ref": ref, "size": file_size},
|
||||
)
|
||||
|
||||
return file_data
|
||||
|
||||
return result
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_file_contents",
|
||||
@@ -326,25 +268,13 @@ class GiteaClient:
|
||||
raise
|
||||
|
||||
async def get_tree(
|
||||
self, owner: str, repo: str, ref: str = "main", recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Get file tree for a repository.
|
||||
|
||||
Args:
|
||||
owner: Repository owner username
|
||||
repo: Repository name
|
||||
ref: Branch, tag, or commit ref (defaults to 'main')
|
||||
recursive: Whether to recursively fetch tree (default: False for safety)
|
||||
|
||||
Returns:
|
||||
Tree information dict
|
||||
|
||||
Raises:
|
||||
GiteaError: On API errors
|
||||
"""
|
||||
if not self.client:
|
||||
raise RuntimeError("Client not initialized - use async context manager")
|
||||
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
ref: str = "main",
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Get repository tree at given ref."""
|
||||
repo_id = f"{owner}/{repo}"
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
@@ -352,24 +282,26 @@ class GiteaClient:
|
||||
params={"ref": ref, "recursive": recursive},
|
||||
result_status="pending",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/git/trees/{ref}",
|
||||
params={"recursive": str(recursive).lower()},
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
tree_data = self._handle_response(response, correlation_id)
|
||||
|
||||
tree_data = result if isinstance(result, dict) else {}
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
repository=repo_id,
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
params={"ref": ref, "recursive": recursive, "count": len(tree_data.get("tree", []))},
|
||||
params={
|
||||
"ref": ref,
|
||||
"recursive": recursive,
|
||||
"count": len(tree_data.get("tree", [])),
|
||||
},
|
||||
)
|
||||
|
||||
return tree_data
|
||||
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_tree",
|
||||
@@ -379,3 +311,326 @@ class GiteaClient:
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
async def search_code(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
query: str,
|
||||
*,
|
||||
ref: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Search repository code by query."""
|
||||
correlation_id = self.audit.log_tool_invocation(
|
||||
tool_name="search_code",
|
||||
repository=f"{owner}/{repo}",
|
||||
params={"query": query, "ref": ref, "page": page, "limit": limit},
|
||||
result_status="pending",
|
||||
)
|
||||
try:
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/search",
|
||||
params={"q": query, "page": page, "limit": limit, "ref": ref},
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="search_code",
|
||||
repository=f"{owner}/{repo}",
|
||||
correlation_id=correlation_id,
|
||||
result_status="success",
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
except Exception as exc:
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="search_code",
|
||||
repository=f"{owner}/{repo}",
|
||||
correlation_id=correlation_id,
|
||||
result_status="error",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
async def list_commits(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
ref: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List commits for a repository ref."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/commits",
|
||||
params={"sha": ref, "page": page, "limit": limit},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="list_commits", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_commit_diff(self, owner: str, repo: str, sha: str) -> dict[str, Any]:
|
||||
"""Get detailed commit including changed files and patch metadata."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="get_commit_diff", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def compare_refs(self, owner: str, repo: str, base: str, head: str) -> dict[str, Any]:
|
||||
"""Compare two refs and return commit/file deltas."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/compare/{base}...{head}",
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="compare_refs", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
state: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
labels: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List repository issues."""
|
||||
params: dict[str, Any] = {"state": state, "page": page, "limit": limit}
|
||||
if labels:
|
||||
params["labels"] = ",".join(labels)
|
||||
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues",
|
||||
params=params,
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="list_issues", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, index: int) -> dict[str, Any]:
|
||||
"""Get issue details."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}",
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="get_issue", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def list_pull_requests(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
state: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List pull requests for repository."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/pulls",
|
||||
params={"state": state, "page": page, "limit": limit},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="list_pull_requests", result_status="pending"
|
||||
)
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def get_pull_request(self, owner: str, repo: str, index: int) -> dict[str, Any]:
|
||||
"""Get a single pull request."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/pulls/{index}",
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="get_pull_request", result_status="pending"
|
||||
)
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def list_labels(
|
||||
self, owner: str, repo: str, *, page: int, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List repository labels."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/labels",
|
||||
params={"page": page, "limit": limit},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="list_labels", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def list_tags(
|
||||
self, owner: str, repo: str, *, page: int, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List repository tags."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/tags",
|
||||
params={"page": page, "limit": limit},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="list_tags", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def list_releases(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List repository releases."""
|
||||
result = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/releases",
|
||||
params={"page": page, "limit": limit},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="list_releases", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, list) else []
|
||||
|
||||
async def create_issue(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create repository issue."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
if assignees:
|
||||
payload["assignees"] = assignees
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues",
|
||||
json_body=payload,
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="create_issue", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def update_issue(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
index: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update issue fields."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if state is not None:
|
||||
payload["state"] = state
|
||||
result = await self._request(
|
||||
"PATCH",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}",
|
||||
json_body=payload,
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="update_issue", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def create_issue_comment(
|
||||
self, owner: str, repo: str, index: int, body: str
|
||||
) -> dict[str, Any]:
|
||||
"""Create a comment on issue (and PR discussion if issue index refers to PR)."""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}/comments",
|
||||
json_body={"body": body},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="create_issue_comment", result_status="pending"
|
||||
)
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def create_pr_comment(
|
||||
self, owner: str, repo: str, index: int, body: str
|
||||
) -> dict[str, Any]:
|
||||
"""Create PR discussion comment."""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}/comments",
|
||||
json_body={"body": body},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="create_pr_comment", result_status="pending"
|
||||
)
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def add_labels(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
index: int,
|
||||
labels: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Add labels to issue/PR."""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}/labels",
|
||||
json_body={"labels": labels},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="add_labels", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def assign_issue(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
index: int,
|
||||
assignees: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Assign users to issue/PR."""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues/{index}/assignees",
|
||||
json_body={"assignees": assignees},
|
||||
correlation_id=str(
|
||||
self.audit.log_tool_invocation(tool_name="assign_issue", result_status="pending")
|
||||
),
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
Reference in New Issue
Block a user