6
from dataclasses import dataclass
7
from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union
8
from urllib.error import HTTPError
9
from urllib.parse import quote
10
from urllib.request import Request, urlopen
13
GITHUB_API_URL = "https://api.github.com"
21
author_association: str
22
editor_login: Optional[str]
27
def gh_fetch_url_and_headers(
30
headers: Optional[Dict[str, str]] = None,
31
data: Union[Optional[Dict[str, Any]], str] = None,
32
method: Optional[str] = None,
33
reader: Callable[[Any], Any] = lambda x: x.read(),
37
token = os.environ.get("GITHUB_TOKEN")
38
if token is not None and url.startswith(f"{GITHUB_API_URL}/"):
39
headers["Authorization"] = f"token {token}"
43
data_ = data.encode() if isinstance(data, str) else json.dumps(data).encode()
46
with urlopen(Request(url, headers=headers, data=data_, method=method)) as conn:
47
return conn.headers, reader(conn)
48
except HTTPError as err:
53
for key in ["X-RateLimit-Limit", "X-RateLimit-Remaining"]
55
and int(err.headers["X-RateLimit-Remaining"]) == 0
60
Used: {err.headers['X-RateLimit-Used']}
61
Limit: {err.headers['X-RateLimit-Limit']}
62
Remaining: {err.headers['X-RateLimit-Remaining']}
63
Resets at: {err.headers['x-RateLimit-Reset']}"""
66
print(f"Error fetching {url} {err}")
73
headers: Optional[Dict[str, str]] = None,
74
data: Union[Optional[Dict[str, Any]], str] = None,
75
method: Optional[str] = None,
76
reader: Callable[[Any], Any] = lambda x: x.read(),
78
return gh_fetch_url_and_headers(
79
url, headers=headers, data=data, reader=json.load, method=method
85
params: Optional[Dict[str, Any]] = None,
86
data: Optional[Dict[str, Any]] = None,
87
method: Optional[str] = None,
88
) -> List[Dict[str, Any]]:
89
headers = {"Accept": "application/vnd.github.v3+json"}
90
if params is not None and len(params) > 0:
91
url += "?" + "&".join(
92
f"{name}={quote(str(val))}" for name, val in params.items()
96
gh_fetch_url(url, headers=headers, data=data, reader=json.load, method=method),
100
def _gh_fetch_json_any(
102
params: Optional[Dict[str, Any]] = None,
103
data: Optional[Dict[str, Any]] = None,
105
headers = {"Accept": "application/vnd.github.v3+json"}
106
if params is not None and len(params) > 0:
107
url += "?" + "&".join(
108
f"{name}={quote(str(val))}" for name, val in params.items()
110
return gh_fetch_url(url, headers=headers, data=data, reader=json.load)
113
def gh_fetch_json_list(
115
params: Optional[Dict[str, Any]] = None,
116
data: Optional[Dict[str, Any]] = None,
117
) -> List[Dict[str, Any]]:
118
return cast(List[Dict[str, Any]], _gh_fetch_json_any(url, params, data))
121
def gh_fetch_json_dict(
123
params: Optional[Dict[str, Any]] = None,
124
data: Optional[Dict[str, Any]] = None,
126
return cast(Dict[str, Any], _gh_fetch_json_any(url, params, data))
129
def gh_graphql(query: str, **kwargs: Any) -> Dict[str, Any]:
131
"https://api.github.com/graphql",
132
data={"query": query, "variables": kwargs},
137
f"GraphQL query {query}, args {kwargs} failed: {rc['errors']}"
139
return cast(Dict[str, Any], rc)
143
url: str, comment: str, dry_run: bool = False
144
) -> List[Dict[str, Any]]:
148
return gh_fetch_json_list(url, data={"body": comment})
151
def gh_post_pr_comment(
152
org: str, repo: str, pr_num: int, comment: str, dry_run: bool = False
153
) -> List[Dict[str, Any]]:
154
return _gh_post_comment(
155
f"{GITHUB_API_URL}/repos/{org}/{repo}/issues/{pr_num}/comments",
161
def gh_post_commit_comment(
162
org: str, repo: str, sha: str, comment: str, dry_run: bool = False
163
) -> List[Dict[str, Any]]:
164
return _gh_post_comment(
165
f"{GITHUB_API_URL}/repos/{org}/{repo}/commits/{sha}/comments",
171
def gh_delete_comment(org: str, repo: str, comment_id: int) -> None:
172
url = f"{GITHUB_API_URL}/repos/{org}/{repo}/issues/comments/{comment_id}"
173
gh_fetch_url(url, method="DELETE")
176
def gh_fetch_merge_base(org: str, repo: str, base: str, head: str) -> str:
182
json_data = gh_fetch_url(
183
f"{GITHUB_API_URL}/repos/{org}/{repo}/compare/{base}...{head}",
184
headers={"Accept": "application/vnd.github.v3+json"},
188
merge_base = json_data.get("merge_base_commit", {}).get("sha", "")
191
f"Failed to get merge base for {base}...{head}: Empty response"
193
except Exception as error:
194
warnings.warn(f"Failed to get merge base for {base}...{head}: {error}")
199
def gh_update_pr_state(org: str, repo: str, pr_num: int, state: str = "open") -> None:
200
url = f"{GITHUB_API_URL}/repos/{org}/{repo}/pulls/{pr_num}"
202
gh_fetch_url(url, method="PATCH", data={"state": state})
203
except HTTPError as err:
206
if err.code == 422 and state == "open":
208
f"Failed to open {pr_num} because its head branch has been deleted: {err}"
214
def gh_query_issues_by_labels(
215
org: str, repo: str, labels: List[str], state: str = "open"
216
) -> List[Dict[str, Any]]:
217
url = f"{GITHUB_API_URL}/repos/{org}/{repo}/issues"
218
return gh_fetch_json(
219
url, method="GET", params={"labels": ",".join(labels), "state": state}