from __future__ import annotations import logging import httpx from githubkit import GitHub from yuxi.channel.extensions.github.auth import GitHubAuth logger = logging.getLogger(__name__) GRAPHQL_ENDPOINT = "https://api.github.com/graphql" class GitHubApiClient: def __init__(self, auth: GitHubAuth, installation_id: int): self._auth = auth self._installation_id = installation_id self._rate_remaining: int | None = None self._rate_reset: int | None = None async def _get_client(self) -> GitHub: token = await self._auth.get_installation_token(self._installation_id) return GitHub(httpx.AsyncClient(timeout=httpx.Timeout(30.0)), token=token) async def create_issue_comment( self, owner: str, repo: str, issue_number: int, body: str, ) -> dict: client = await self._get_client() resp = await client.rest.issues.create_comment( owner=owner, repo=repo, issue_number=issue_number, body=body, ) comment = resp.parsed_data return {"id": str(comment.id), "html_url": comment.html_url, "success": True} async def update_issue_comment( self, owner: str, repo: str, comment_id: int, body: str, ) -> dict: client = await self._get_client() resp = await client.rest.issues.update_comment( owner=owner, repo=repo, comment_id=comment_id, body=body, ) comment = resp.parsed_data return {"id": str(comment.id), "html_url": comment.html_url, "success": True} async def delete_issue_comment( self, owner: str, repo: str, comment_id: int, ) -> dict: client = await self._get_client() await client.rest.issues.delete_comment( owner=owner, repo=repo, comment_id=comment_id, ) return {"success": True} async def get_issue( self, owner: str, repo: str, issue_number: int, ) -> dict: client = await self._get_client() resp = await client.rest.issues.get( owner=owner, repo=repo, issue_number=issue_number, ) issue = resp.parsed_data return { "number": issue.number, "title": issue.title, "body": issue.body, "state": issue.state, "html_url": issue.html_url, "labels": [lb.name for lb in (issue.labels or [])], } async def create_issue( self, owner: str, repo: str, title: str, body: str = "", labels: list[str] | None = None, ) -> dict: client = await self._get_client() resp = await client.rest.issues.create( owner=owner, repo=repo, title=title, body=body, labels=labels, ) issue = resp.parsed_data return {"number": issue.number, "html_url": issue.html_url, "success": True} async def close_issue( self, owner: str, repo: str, issue_number: int, ) -> dict: client = await self._get_client() resp = await client.rest.issues.update( owner=owner, repo=repo, issue_number=issue_number, state="closed", ) issue = resp.parsed_data return {"number": issue.number, "state": issue.state, "success": True} async def reopen_issue( self, owner: str, repo: str, issue_number: int, ) -> dict: client = await self._get_client() resp = await client.rest.issues.update( owner=owner, repo=repo, issue_number=issue_number, state="open", ) issue = resp.parsed_data return {"number": issue.number, "state": issue.state, "success": True} async def add_labels( self, owner: str, repo: str, issue_number: int, labels: list[str], ) -> dict: client = await self._get_client() resp = await client.rest.issues.add_labels( owner=owner, repo=repo, issue_number=issue_number, labels=labels, ) result_labels = resp.parsed_data return {"labels": [lb.name for lb in result_labels], "success": True} async def remove_labels( self, owner: str, repo: str, issue_number: int, labels: list[str], ) -> dict: client = await self._get_client() for label in labels: await client.rest.issues.remove_label( owner=owner, repo=repo, issue_number=issue_number, name=label, ) return {"success": True} async def list_issues( self, owner: str, repo: str, *, state: str = "open", labels: str = "", per_page: int = 30, ) -> list[dict]: client = await self._get_client() kwargs = {"owner": owner, "repo": repo, "state": state, "per_page": per_page} if labels: kwargs["labels"] = labels resp = await client.rest.issues.list_for_repo(**kwargs) return [ { "number": item.number, "title": item.title, "state": item.state, "html_url": item.html_url, "labels": [lb.name for lb in (item.labels or [])], } for item in resp.parsed_data ] async def set_assignees( self, owner: str, repo: str, issue_number: int, assignees: list[str], ) -> dict: client = await self._get_client() resp = await client.rest.issues.update( owner=owner, repo=repo, issue_number=issue_number, assignees=assignees, ) issue = resp.parsed_data result_assignees = [a.login for a in (issue.assignees or [])] return {"number": issue.number, "assignees": result_assignees, "success": True} async def get_issue_comments( self, owner: str, repo: str, issue_number: int, *, per_page: int = 30, ) -> list[dict]: client = await self._get_client() resp = await client.rest.issues.list_comments( owner=owner, repo=repo, issue_number=issue_number, per_page=per_page, ) return [ { "id": item.id, "body": item.body, "user": item.user.login if item.user else "", "created_at": item.created_at.isoformat() if item.created_at else "", "html_url": item.html_url, } for item in resp.parsed_data ] async def create_reaction( self, owner: str, repo: str, issue_number: int, content: str, *, subject_type: str = "issue", ) -> dict: client = await self._get_client() try: kwargs = {"owner": owner, "repo": repo, "content": content} if subject_type in ("issue", "pull_request"): kwargs["issue_number"] = issue_number resp = await client.rest.reactions.create_for_issue(**kwargs) elif subject_type == "issue_comment": kwargs["comment_id"] = issue_number resp = await client.rest.reactions.create_for_issue_comment(**kwargs) elif subject_type == "pull_request_review_comment": kwargs["comment_id"] = issue_number resp = await client.rest.reactions.create_for_pull_request_review_comment(**kwargs) elif subject_type == "discussion": return await self._create_discussion_reaction(issue_number, content) else: return {"success": False, "error": f"unsupported subject_type: {subject_type}"} reaction = resp.parsed_data return {"id": str(reaction.id), "content": reaction.content, "success": True} except Exception as e: logger.warning("GitHub create_reaction failed: %s", e) return {"success": False, "error": str(e)} async def _create_discussion_reaction(self, discussion_node_id: str, content: str) -> dict: mutation = """ mutation($subjectId: ID!, $content: ReactionContent!) { addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { id content } } } """ data = await self.graphql_query(mutation, { "subjectId": discussion_node_id, "content": content.upper(), }) if "errors" in data: return {"success": False, "error": data["errors"][0].get("message", "GraphQL error")} reaction = data.get("data", {}).get("addReaction", {}).get("reaction", {}) return {"id": reaction.get("id", ""), "content": reaction.get("content", ""), "success": True} async def graphql_query(self, query: str, variables: dict | None = None) -> dict: token = await self._auth.get_installation_token(self._installation_id) headers = { "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", } async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: resp = await client.post( GRAPHQL_ENDPOINT, json={"query": query, "variables": variables or {}}, headers=headers, ) resp.raise_for_status() return resp.json() async def add_discussion_comment( self, discussion_node_id: str, body: str, reply_to_id: str | None = None, ) -> dict: mutation = """ mutation($discussionId: ID!, $body: String!, $replyToId: ID) { addDiscussionComment(input: { discussionId: $discussionId, body: $body, replyToId: $replyToId }) { comment { id body url } } } """ variables = { "discussionId": discussion_node_id, "body": body, "replyToId": reply_to_id, } data = await self.graphql_query(mutation, variables) if "errors" in data: return {"success": False, "error": data["errors"][0].get("message", "GraphQL error")} comment = data.get("data", {}).get("addDiscussionComment", {}).get("comment", {}) return {"id": comment.get("id", ""), "url": comment.get("url", ""), "success": True} async def update_discussion_comment( self, comment_node_id: str, body: str, ) -> dict: mutation = """ mutation($commentId: ID!, $body: String!) { updateDiscussionComment(input: { commentId: $commentId, body: $body }) { comment { id body url } } } """ data = await self.graphql_query(mutation, { "commentId": comment_node_id, "body": body, }) if "errors" in data: return {"success": False, "error": data["errors"][0].get("message", "GraphQL error")} comment = data.get("data", {}).get("updateDiscussionComment", {}).get("comment", {}) return {"id": comment.get("id", ""), "url": comment.get("url", ""), "success": True} async def delete_discussion_comment( self, comment_node_id: str, ) -> dict: mutation = """ mutation($commentId: ID!) { deleteDiscussionComment(input: { commentId: $commentId }) { comment { id } } } """ data = await self.graphql_query(mutation, {"commentId": comment_node_id}) if "errors" in data: return {"success": False, "error": data["errors"][0].get("message", "GraphQL error")} return {"success": True}