新增了完整的GitHub集成插件,包含认证、会话管理、Webhook处理、消息收发、反应支持等核心功能,支持Issues、Discussions、Pull Request的交互与通知。
306 lines
11 KiB
Python
306 lines
11 KiB
Python
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}
|