-
Notifications
You must be signed in to change notification settings - Fork 14
github: move all GitHub logic to dedicated class (bug 1996507, bug 1995679) #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
85fba5e
4b5285b
7c0d34d
dc3ebcb
222bef8
0e2ec95
7c1d6a8
b8472c8
c9ec66a
b57ce51
3d9712a
4563f49
ad30b6c
e49bb55
4493dee
12b2be3
dc6994e
09000ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import re | ||
|
|
||
| # From RFC-3986 [0]: | ||
| # | ||
| # userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) | ||
| # | ||
| # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" | ||
| # pct-encoded = "%" HEXDIG HEXDIG | ||
| # sub-delims = "!" / "$" / "&" / "'" / "(" / ")" | ||
| # / "*" / "+" / "," / ";" / "= | ||
| # | ||
| # [0] https://www.rfc-editor.org/rfc/rfc3986 | ||
| URL_USERINFO_RE = re.compile( | ||
| "(?P<userinfo>[-A-Za-z0-9:._~%!$&'*()*+;=]*:[-A-Za-z0-9:._~%!$&'*()*+;=]*@)", | ||
| flags=re.MULTILINE, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,36 +1,82 @@ | ||
| import asyncio | ||
| import logging | ||
| import re | ||
|
|
||
| import requests | ||
| from django.conf import settings | ||
| from simple_github import AppAuth, AppInstallationAuth | ||
|
|
||
| from lando.main.models.repo import Repo | ||
| from lando.utils.const import URL_USERINFO_RE | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class GitHubAPI: | ||
| """A simple wrapper that authenticates with and communicates with the GitHub API.""" | ||
| class GitHub: | ||
| """Work with authentication to GitHub repositories.""" | ||
|
|
||
| GITHUB_BASE_URL = "https://api.github.com" | ||
| GITHUB_URL_RE = re.compile( | ||
| rf"https://{URL_USERINFO_RE.pattern}?github.com/(?P<owner>[-A-Za-z0-9]+)/(?P<repo>[^/]+?)(?:\.git)?(?:/|$)" | ||
| ) | ||
|
|
||
| def __init__(self, repo: Repo): | ||
| repo_owner = repo._github_repo_org | ||
| repo_name = repo.git_repo_name | ||
| repo_url: str | ||
| repo_owner: str | ||
| repo_name: str | ||
| userinfo: str | ||
|
|
||
| self.session = requests.Session() | ||
| self.session.headers.update( | ||
| { | ||
| "Authorization": f"Bearer {self._get_token(repo_owner, repo_name)}", | ||
| "User-Agent": settings.HTTP_USER_AGENT, | ||
| "Accept": "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| } | ||
| def __init__(self, repo_url: str): | ||
| self.repo_url = repo_url | ||
|
|
||
| parsed_url_data = self.parse_url(self.repo_url) | ||
|
|
||
| if parsed_url_data is None: | ||
| raise ValueError(f"Cannot parse URL as GitHub repo: {repo_url}") | ||
|
|
||
| self.repo_owner = parsed_url_data["owner"] | ||
| self.repo_name = parsed_url_data["repo"] | ||
| self.userinfo = parsed_url_data["userinfo"] | ||
|
|
||
| @classmethod | ||
| def is_supported_url(cls, url: str) -> bool: | ||
| """Determine whether the passed URL is a supported GitHub URL.""" | ||
| return cls.parse_url(url) is not None | ||
|
|
||
| @classmethod | ||
| def parse_url(cls, url: str) -> re.Match[str] | None: | ||
| """Parse GitHub data from URL, or return None if not Github. | ||
|
|
||
| Note: no normalisation is performed on the URL | ||
| """ | ||
| return re.match(cls.GITHUB_URL_RE, url) | ||
|
|
||
| @property | ||
| def authenticated_url(self) -> str: | ||
|
shtrom marked this conversation as resolved.
|
||
| """Return an authenticated URL, suitable for use with `git` to push and pull. | ||
|
|
||
| If the URL already has authentication parameters, it is returned verbatim. If | ||
| not, a token is fetched by the GitHub app, and inserted into the USERINFO part of | ||
| the URL, without any other changes (e.g., in the REST path or Query String). | ||
| """ | ||
| if self.userinfo: | ||
| # We only fetch a token if no authentication is explicitly specified in | ||
| # the repo_url. | ||
| return self.repo_url | ||
|
|
||
| logger.info( | ||
| f"Obtaining fresh GitHub token for GitHub repo at {self.repo_url}", | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _get_token(repo_owner: str, repo_name: str) -> str | None: | ||
| token = self._fetch_token() | ||
|
|
||
| if token: | ||
| return self.repo_url.replace( | ||
| "https://github.com", f"https://git:{token}@github.com" | ||
| ) | ||
|
|
||
| # We didn't get a token | ||
| logger.warning(f"Couldn't obtain a token for GitHub repo at {self.repo_url}") | ||
| return self.repo_url | ||
|
Comment on lines
+75
to
+77
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this fail more loudly? If we're expecting credentials in the URL and we can't get them, maybe this should throw an exception instead.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think if it doesn't fail here, it will fail when trying to push, and then the warning should give us a clue.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think given that the purpose of this method is to return an authenticated URL, not returning one should lead to a failure, rather than a warning. Won't block this but this could potentially obfuscate issues down the road. |
||
|
|
||
| def _fetch_token(self) -> str | None: | ||
|
shtrom marked this conversation as resolved.
|
||
| """Obtain a fresh GitHub token to push to the specified repo. | ||
|
|
||
| This relies on GITHUB_APP_ID and GITHUB_APP_PRIVKEY to be set in the | ||
|
|
@@ -44,17 +90,40 @@ def _get_token(repo_owner: str, repo_name: str) -> str | None: | |
|
|
||
| if not app_id or not private_key: | ||
| logger.warning( | ||
| f"Missing GITHUB_APP_ID or GITHUB_APP_PRIVKEY to authenticate against GitHub repo {repo_owner}/{repo_name}", | ||
| f"Missing GITHUB_APP_ID or GITHUB_APP_PRIVKEY to authenticate against GitHub repo at {self.repo_url}", | ||
| ) | ||
| return None | ||
|
|
||
| app_auth = AppAuth( | ||
| app_id, | ||
| private_key, | ||
| ) | ||
| session = AppInstallationAuth(app_auth, repo_owner, repositories=[repo_name]) | ||
| session = AppInstallationAuth( | ||
| app_auth, self.repo_owner, repositories=[self.repo_name] | ||
| ) | ||
| return asyncio.run(session.get_token()) | ||
|
|
||
|
|
||
| class GitHubAPI(GitHub): | ||
| """A simple wrapper that authenticates with and communicates with the GitHub API.""" | ||
|
|
||
| session: requests.Session | ||
|
|
||
| GITHUB_BASE_URL = "https://api.github.com" | ||
|
|
||
| def __init__(self, repo_url: str): | ||
| super().__init__(repo_url) | ||
|
|
||
| self.session = requests.Session() | ||
| self.session.headers.update( | ||
| { | ||
| "Authorization": f"Bearer {self._fetch_token()}", | ||
| "User-Agent": settings.HTTP_USER_AGENT, | ||
| "Accept": "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| } | ||
| ) | ||
|
shtrom marked this conversation as resolved.
|
||
|
|
||
| def get(self, path: str, *args, **kwargs) -> requests.Response: | ||
| """Send a GET request to the GitHub API with given args and kwargs.""" | ||
| url = f"{self.GITHUB_BASE_URL}/{path}" | ||
|
|
@@ -71,12 +140,9 @@ class GitHubAPIClient: | |
|
|
||
| _api: GitHubAPI | ||
|
|
||
| def __init__(self, repo: Repo): | ||
| self._api = GitHubAPI(repo) | ||
| self.repo = repo | ||
| self.repo_base_url = ( | ||
| f"repos/{self.repo._github_repo_org}/{self.repo.git_repo_name}" | ||
| ) | ||
| def __init__(self, repo_url: str): | ||
| self._api = GitHubAPI(repo_url) | ||
| self.repo_base_url = f"repos/{self._api.repo_owner}/{self._api.repo_name}" | ||
|
|
||
| def _repo_get(self, subpath: str, *args, **kwargs) -> dict | list: | ||
| """Get API endpoint scoped to the repo_base_url. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.