ForcePilot/backend/package/yuxi/channel/extensions/github/api.py
Kris 702fcd4c33 feat(github): 新增GitHub渠道插件完整实现
新增了完整的GitHub集成插件,包含认证、会话管理、Webhook处理、消息收发、反应支持等核心功能,支持Issues、Discussions、Pull Request的交互与通知。
2026-05-21 10:48:42 +08:00

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}