viraja1 commited on
Commit
058ee3d
·
verified ·
1 Parent(s): fc0f264

Create github_client.py

Browse files
Files changed (1) hide show
  1. github_client.py +187 -0
github_client.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight async GitHub API client used by the MCP server."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import binascii
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Iterable, List, Optional
9
+
10
+ import httpx
11
+
12
+
13
+ class GitHubAPIError(RuntimeError):
14
+ """Raised when the GitHub API returns an error response."""
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class GitHubClient:
19
+ """Thin wrapper around the GitHub REST API."""
20
+
21
+ token: Optional[str] = None
22
+ base_url: str = "https://api.github.com"
23
+ timeout: float = 15.0
24
+
25
+ def __post_init__(self) -> None:
26
+ if not self.token:
27
+ self.token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_ACCESS_TOKEN")
28
+ self.base_url = self.base_url.rstrip("/")
29
+
30
+ def _headers(self) -> Dict[str, str]:
31
+ headers = {
32
+ "Accept": "application/vnd.github+json",
33
+ "User-Agent": "github-chatgpt-app",
34
+ }
35
+ if self.token:
36
+ headers["Authorization"] = f"Bearer {self.token}"
37
+ return headers
38
+
39
+ async def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any:
40
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
41
+ try:
42
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
43
+ response = await client.get(url, headers=self._headers(), params=params)
44
+ response.raise_for_status()
45
+ return response.json()
46
+ except httpx.HTTPStatusError as exc:
47
+ detail = exc.response.json() if exc.response.content else {"message": exc.response.text}
48
+ message = detail.get("message", "GitHub API error")
49
+ raise GitHubAPIError(f"{message} (status {exc.response.status_code})") from exc
50
+ except httpx.RequestError as exc:
51
+ raise GitHubAPIError(f"Unable to reach GitHub: {exc}") from exc
52
+
53
+ async def get_repo_info(self, owner: str, repo: str) -> Dict[str, Any]:
54
+ return await self._get(f"repos/{owner}/{repo}")
55
+
56
+ async def list_issues(
57
+ self,
58
+ owner: str,
59
+ repo: str,
60
+ state: str = "open",
61
+ labels: Optional[Iterable[str]] = None,
62
+ limit: int = 25,
63
+ include_pull_requests: bool = False,
64
+ ) -> List[Dict[str, Any]]:
65
+ params: Dict[str, Any] = {
66
+ "state": state,
67
+ "per_page": min(max(limit, 1), 100),
68
+ }
69
+ if labels:
70
+ params["labels"] = ",".join(labels)
71
+ data = await self._get(f"repos/{owner}/{repo}/issues", params=params)
72
+ if include_pull_requests:
73
+ return data
74
+ return [issue for issue in data if "pull_request" not in issue]
75
+
76
+ async def list_pull_requests(
77
+ self,
78
+ owner: str,
79
+ repo: str,
80
+ state: str = "open",
81
+ limit: int = 25,
82
+ ) -> List[Dict[str, Any]]:
83
+ params = {
84
+ "state": state,
85
+ "per_page": min(max(limit, 1), 100),
86
+ }
87
+ return await self._get(f"repos/{owner}/{repo}/pulls", params=params)
88
+
89
+ async def get_readme(self, owner: str, repo: str, ref: Optional[str] = None) -> Dict[str, Any]:
90
+ params = {"ref": ref} if ref else None
91
+ try:
92
+ data = await self._get(f"repos/{owner}/{repo}/readme", params=params)
93
+ except GitHubAPIError as exc:
94
+ message = str(exc)
95
+ if "status 404" in message:
96
+ return {
97
+ "name": None,
98
+ "path": None,
99
+ "sha": None,
100
+ "content": "",
101
+ "error": "README not found",
102
+ }
103
+ raise
104
+ raw_content = data.get("content", "") or ""
105
+ encoding = (data.get("encoding") or "base64").lower()
106
+ decoded = ""
107
+ error: Optional[str] = None
108
+ if encoding == "base64":
109
+ try:
110
+ decoded_bytes = base64.b64decode(raw_content, validate=False)
111
+ decoded = decoded_bytes.decode("utf-8", errors="replace")
112
+ except (binascii.Error, UnicodeDecodeError):
113
+ error = "README content could not be decoded from base64."
114
+ else:
115
+ if isinstance(raw_content, str):
116
+ decoded = raw_content
117
+ else:
118
+ try:
119
+ decoded = raw_content.decode("utf-8", errors="replace")
120
+ except Exception:
121
+ decoded = str(raw_content)
122
+ error = f"Unsupported README encoding: {encoding}"
123
+ return {
124
+ "name": data.get("name"),
125
+ "path": data.get("path"),
126
+ "sha": data.get("sha"),
127
+ "content": decoded,
128
+ "error": error,
129
+ }
130
+
131
+ async def list_contributors(
132
+ self,
133
+ owner: str,
134
+ repo: str,
135
+ limit: int = 25,
136
+ include_anonymous: bool = False,
137
+ ) -> List[Dict[str, Any]]:
138
+ params = {
139
+ "per_page": min(max(limit, 1), 100),
140
+ "anon": str(include_anonymous).lower(),
141
+ }
142
+ return await self._get(f"repos/{owner}/{repo}/contributors", params=params)
143
+
144
+ async def list_files(
145
+ self,
146
+ owner: str,
147
+ repo: str,
148
+ ref: str = "main",
149
+ directory: Optional[str] = None,
150
+ limit: int = 200,
151
+ ) -> List[Dict[str, Any]]:
152
+ params = {"recursive": "1"}
153
+ data = await self._get(f"repos/{owner}/{repo}/git/trees/{ref}", params=params)
154
+ tree = data.get("tree", [])
155
+ if directory:
156
+ normalized = directory.strip("/")
157
+ tree = [node for node in tree if node.get("path", "").startswith(normalized)]
158
+ limited = tree[: min(max(limit, 1), len(tree))]
159
+ return [
160
+ {
161
+ "path": node.get("path"),
162
+ "type": node.get("type"),
163
+ "size": node.get("size"),
164
+ "sha": node.get("sha"),
165
+ }
166
+ for node in limited
167
+ ]
168
+
169
+ async def search_repositories(
170
+ self,
171
+ query: str,
172
+ sort: Optional[str] = None,
173
+ order: str = "desc",
174
+ limit: int = 25,
175
+ ) -> List[Dict[str, Any]]:
176
+ params: Dict[str, Any] = {
177
+ "q": query,
178
+ "per_page": min(max(limit, 1), 100),
179
+ "order": order,
180
+ }
181
+ if sort:
182
+ params["sort"] = sort
183
+ data = await self._get("search/repositories", params=params)
184
+ return data.get("items", [])
185
+
186
+
187
+ __all__ = ["GitHubClient", "GitHubAPIError"]