|
@@ -1,61 +1,91 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from datetime import datetime
|
|
|
+from typing import Any, Mapping, Sequence
|
|
|
|
|
|
import sentry_sdk
|
|
|
+from rest_framework.response import Response
|
|
|
|
|
|
from sentry.integrations.client import ApiClient
|
|
|
from sentry.integrations.github.utils import get_jwt
|
|
|
-from sentry.models import Repository
|
|
|
+from sentry.models import Integration, Repository
|
|
|
from sentry.utils import jwt
|
|
|
+from sentry.utils.json import JSONData
|
|
|
|
|
|
|
|
|
-class GitHubClientMixin(ApiClient):
|
|
|
+class GitHubClientMixin(ApiClient): # type: ignore
|
|
|
allow_redirects = True
|
|
|
|
|
|
base_url = "https://api.github.com"
|
|
|
integration_name = "github"
|
|
|
|
|
|
- def get_jwt(self):
|
|
|
+ def get_jwt(self) -> str:
|
|
|
return get_jwt()
|
|
|
|
|
|
- def get_last_commits(self, repo, end_sha):
|
|
|
- # return api request that fetches last ~30 commits
|
|
|
- # see https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository
|
|
|
- # using end_sha as parameter
|
|
|
- return self.get_cached(f"/repos/{repo}/commits", params={"sha": end_sha})
|
|
|
-
|
|
|
- def compare_commits(self, repo, start_sha, end_sha):
|
|
|
- # see https://developer.github.com/v3/repos/commits/#compare-two-commits
|
|
|
- # where start sha is oldest and end is most recent
|
|
|
- return self.get_cached(f"/repos/{repo}/compare/{start_sha}...{end_sha}")
|
|
|
-
|
|
|
- def repo_hooks(self, repo):
|
|
|
- return self.get(f"/repos/{repo}/hooks")
|
|
|
-
|
|
|
- def get_commits(self, repo):
|
|
|
- return self.get(f"/repos/{repo}/commits")
|
|
|
-
|
|
|
- def get_commit(self, repo, sha):
|
|
|
- return self.get_cached(f"/repos/{repo}/commits/{sha}")
|
|
|
-
|
|
|
- def get_repo(self, repo):
|
|
|
- return self.get(f"/repos/{repo}")
|
|
|
+ def get_last_commits(self, repo: str, end_sha: str) -> Sequence[JSONData]:
|
|
|
+ """
|
|
|
+ Return API request that fetches last ~30 commits
|
|
|
+ see https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository
|
|
|
+ using end_sha as parameter.
|
|
|
+ """
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ commits: Sequence[JSONData] = self.get_cached(
|
|
|
+ f"/repos/{repo}/commits", params={"sha": end_sha}
|
|
|
+ )
|
|
|
+ return commits
|
|
|
|
|
|
- def get_repositories(self):
|
|
|
- repositories = self.get("/installation/repositories", params={"per_page": 100})
|
|
|
+ def compare_commits(self, repo: str, start_sha: str, end_sha: str) -> JSONData:
|
|
|
+ """
|
|
|
+ See https://developer.github.com/v3/repos/commits/#compare-two-commits
|
|
|
+ where start sha is oldest and end is most recent.
|
|
|
+ """
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ diff: JSONData = self.get_cached(f"/repos/{repo}/compare/{start_sha}...{end_sha}")
|
|
|
+ return diff
|
|
|
+
|
|
|
+ def repo_hooks(self, repo: str) -> Sequence[JSONData]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ hooks: Sequence[JSONData] = self.get(f"/repos/{repo}/hooks")
|
|
|
+ return hooks
|
|
|
+
|
|
|
+ def get_commits(self, repo: str) -> Sequence[JSONData]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ commits: Sequence[JSONData] = self.get(f"/repos/{repo}/commits")
|
|
|
+ return commits
|
|
|
+
|
|
|
+ def get_commit(self, repo: str, sha: str) -> JSONData:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ commit: JSONData = self.get_cached(f"/repos/{repo}/commits/{sha}")
|
|
|
+ return commit
|
|
|
+
|
|
|
+ def get_repo(self, repo: str) -> JSONData:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ repository: JSONData = self.get(f"/repos/{repo}")
|
|
|
+ return repository
|
|
|
+
|
|
|
+ def get_repositories(self) -> Sequence[JSONData]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ repositories: JSONData = self.get("/installation/repositories", params={"per_page": 100})
|
|
|
repos = repositories["repositories"]
|
|
|
return [repo for repo in repos if not repo.get("archived")]
|
|
|
|
|
|
- def search_repositories(self, query):
|
|
|
- return self.get("/search/repositories", params={"q": query})
|
|
|
+ def search_repositories(self, query: bytes) -> Mapping[str, Sequence[JSONData]]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ repositories: Mapping[str, Sequence[JSONData]] = self.get(
|
|
|
+ "/search/repositories", params={"q": query}
|
|
|
+ )
|
|
|
+ return repositories
|
|
|
|
|
|
- def get_assignees(self, repo):
|
|
|
- return self.get_with_pagination(f"/repos/{repo}/assignees")
|
|
|
+ def get_assignees(self, repo: str) -> Sequence[JSONData]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ assignees: Sequence[JSONData] = self.get_with_pagination(f"/repos/{repo}/assignees")
|
|
|
+ return assignees
|
|
|
|
|
|
- def get_with_pagination(self, path, *args, **kwargs):
|
|
|
+ def get_with_pagination(self, path: str, *args: Any, **kwargs: Any) -> Sequence[JSONData]:
|
|
|
"""
|
|
|
- Github uses the Link header to provide pagination links. Github recommends using the provided link relations and not constructing our own URL.
|
|
|
+ Github uses the Link header to provide pagination links. Github
|
|
|
+ recommends using the provided link relations and not constructing our
|
|
|
+ own URL.
|
|
|
https://docs.github.com/en/rest/guides/traversing-with-pagination
|
|
|
"""
|
|
|
try:
|
|
@@ -78,18 +108,21 @@ class GitHubClientMixin(ApiClient):
|
|
|
output.extend(resp)
|
|
|
page_number = 1
|
|
|
|
|
|
- def get_next_link(resp):
|
|
|
- link = resp.headers.get("link")
|
|
|
- if link is None:
|
|
|
+ # TODO(mgaeta): Move this to utils.
|
|
|
+ def get_next_link(resp: Response) -> str | None:
|
|
|
+ link_option: str | None = resp.headers.get("link")
|
|
|
+ if link_option is None:
|
|
|
return None
|
|
|
|
|
|
# Should be a comma separated string of links
|
|
|
- links = link.split(",")
|
|
|
+ links = link_option.split(",")
|
|
|
|
|
|
for link in links:
|
|
|
# If there is a 'next' link return the URL between the angle brackets, or None
|
|
|
if 'rel="next"' in link:
|
|
|
- return link[link.find("<") + 1 : link.find(">")]
|
|
|
+ start = link.find("<") + 1
|
|
|
+ end = link.find(">")
|
|
|
+ return link[start:end]
|
|
|
|
|
|
return None
|
|
|
|
|
@@ -99,27 +132,39 @@ class GitHubClientMixin(ApiClient):
|
|
|
page_number += 1
|
|
|
return output
|
|
|
|
|
|
- def get_issues(self, repo):
|
|
|
- return self.get(f"/repos/{repo}/issues")
|
|
|
+ def get_issues(self, repo: str) -> Sequence[JSONData]:
|
|
|
+ issues: Sequence[JSONData] = self.get(f"/repos/{repo}/issues")
|
|
|
+ return issues
|
|
|
|
|
|
- def search_issues(self, query):
|
|
|
- return self.get("/search/issues", params={"q": query})
|
|
|
+ def search_issues(self, query: str) -> Mapping[str, Sequence[Mapping[str, Any]]]:
|
|
|
+ # Explicitly typing to satisfy mypy.
|
|
|
+ issues: Mapping[str, Sequence[Mapping[str, Any]]] = self.get(
|
|
|
+ "/search/issues", params={"q": query}
|
|
|
+ )
|
|
|
+ return issues
|
|
|
|
|
|
- def get_issue(self, repo, number):
|
|
|
+ def get_issue(self, repo: str, number: str) -> JSONData:
|
|
|
return self.get(f"/repos/{repo}/issues/{number}")
|
|
|
|
|
|
- def create_issue(self, repo, data):
|
|
|
+ def create_issue(self, repo: str, data: Mapping[str, Any]) -> JSONData:
|
|
|
endpoint = f"/repos/{repo}/issues"
|
|
|
return self.post(endpoint, data=data)
|
|
|
|
|
|
- def create_comment(self, repo, issue_id, data):
|
|
|
+ def create_comment(self, repo: str, issue_id: str, data: Mapping[str, Any]) -> JSONData:
|
|
|
endpoint = f"/repos/{repo}/issues/{issue_id}/comments"
|
|
|
return self.post(endpoint, data=data)
|
|
|
|
|
|
- def get_user(self, gh_username):
|
|
|
+ def get_user(self, gh_username: str) -> JSONData:
|
|
|
return self.get(f"/users/{gh_username}")
|
|
|
|
|
|
- def request(self, method, path, headers=None, data=None, params=None):
|
|
|
+ def request(
|
|
|
+ self,
|
|
|
+ method: str,
|
|
|
+ path: str,
|
|
|
+ headers: Mapping[str, Any] | None = None,
|
|
|
+ data: Mapping[str, Any] | None = None,
|
|
|
+ params: Mapping[str, Any] | None = None,
|
|
|
+ ) -> JSONData:
|
|
|
if headers is None:
|
|
|
headers = {
|
|
|
"Authorization": f"token {self.get_token()}",
|
|
@@ -128,31 +173,31 @@ class GitHubClientMixin(ApiClient):
|
|
|
}
|
|
|
return self._request(method, path, headers=headers, data=data, params=params)
|
|
|
|
|
|
- def get_token(self, force_refresh=False):
|
|
|
+ def get_token(self, force_refresh: bool = False) -> str:
|
|
|
"""
|
|
|
Get token retrieves the active access token from the integration model.
|
|
|
Should the token have expired, a new token will be generated and
|
|
|
automatically persisted into the integration.
|
|
|
"""
|
|
|
- token = self.integration.metadata.get("access_token")
|
|
|
- expires_at = self.integration.metadata.get("expires_at")
|
|
|
-
|
|
|
- if expires_at is not None:
|
|
|
- expires_at = datetime.strptime(expires_at, "%Y-%m-%dT%H:%M:%S")
|
|
|
-
|
|
|
- if not token or expires_at < datetime.utcnow() or force_refresh:
|
|
|
+ token: str | None = self.integration.metadata.get("access_token")
|
|
|
+ expires_at: str | None = self.integration.metadata.get("expires_at")
|
|
|
+
|
|
|
+ if (
|
|
|
+ not token
|
|
|
+ or not expires_at
|
|
|
+ or (datetime.strptime(expires_at, "%Y-%m-%dT%H:%M:%S") < datetime.utcnow())
|
|
|
+ or force_refresh
|
|
|
+ ):
|
|
|
res = self.create_token()
|
|
|
token = res["token"]
|
|
|
- expires_at = datetime.strptime(res["expires_at"], "%Y-%m-%dT%H:%M:%SZ")
|
|
|
+ expires_at = datetime.strptime(res["expires_at"], "%Y-%m-%dT%H:%M:%SZ").isoformat()
|
|
|
|
|
|
- self.integration.metadata.update(
|
|
|
- {"access_token": token, "expires_at": expires_at.isoformat()}
|
|
|
- )
|
|
|
+ self.integration.metadata.update({"access_token": token, "expires_at": expires_at})
|
|
|
self.integration.save()
|
|
|
|
|
|
- return token
|
|
|
+ return token or ""
|
|
|
|
|
|
- def create_token(self):
|
|
|
+ def create_token(self) -> JSONData:
|
|
|
headers = {
|
|
|
# TODO(jess): remove this whenever it's out of preview
|
|
|
"Accept": "application/vnd.github.machine-man-preview+json",
|
|
@@ -164,15 +209,19 @@ class GitHubClientMixin(ApiClient):
|
|
|
)
|
|
|
|
|
|
def check_file(self, repo: Repository, path: str, version: str) -> str | None:
|
|
|
- repo_name = repo.name
|
|
|
- return self.head_cached(path=f"/repos/{repo_name}/contents/{path}", params={"ref": version})
|
|
|
+ file: str = self.head_cached(
|
|
|
+ path=f"/repos/{repo.name}/contents/{path}", params={"ref": version}
|
|
|
+ )
|
|
|
+ return file
|
|
|
|
|
|
- def search_file(self, repo, filename):
|
|
|
+ def search_file(
|
|
|
+ self, repo: Repository, filename: str
|
|
|
+ ) -> Mapping[str, Sequence[Mapping[str, Any]]]:
|
|
|
query = f"filename:{filename}+repo:{repo}"
|
|
|
- results = self.get(path="/search/code", params={"q": query})
|
|
|
+ results: Mapping[str, Any] = self.get(path="/search/code", params={"q": query})
|
|
|
return results
|
|
|
|
|
|
- def get_file(self, repo, path):
|
|
|
+ def get_file(self, repo: str, path: str) -> bytes:
|
|
|
from base64 import b64decode
|
|
|
|
|
|
# default ref will be the default_branch
|
|
@@ -182,6 +231,6 @@ class GitHubClientMixin(ApiClient):
|
|
|
|
|
|
|
|
|
class GitHubAppsClient(GitHubClientMixin):
|
|
|
- def __init__(self, integration):
|
|
|
+ def __init__(self, integration: Integration) -> None:
|
|
|
self.integration = integration
|
|
|
super().__init__()
|