diff --git a/Default.sublime-commands b/Default.sublime-commands index cfac3ac3e..453a467c5 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -125,9 +125,14 @@ "command": "gs_github_open_repo" }, { - "caption": "github: review pull request", + "caption": "github: review pull request (show: all)", "command": "gs_github_pull_request" }, + { + "caption": "github: review pull request (show: mine)", + "command": "gs_github_pull_request", + "args": { "query": "involves:@me" } + }, { "caption": "github: set remote for integration", "command": "gs_github_configure_remote" diff --git a/github/commands/pull_request.py b/github/commands/pull_request.py index c55b96516..eaed94d40 100644 --- a/github/commands/pull_request.py +++ b/github/commands/pull_request.py @@ -7,11 +7,12 @@ from ...common import interwebs from ...common import util from ...core.commands.push import gs_push_to_branch_name +from ...core.fns import filter_ from ...core.ui_mixins.quick_panel import show_paginated_panel from ...core.ui_mixins.input_panel import show_single_line_input_panel from ...core.view import replace_view_content from GitSavvy.core.base_commands import GsWindowCommand -from GitSavvy.core.runtime import on_worker +from GitSavvy.core.runtime import on_worker, run_as_future __all__ = ( @@ -27,18 +28,29 @@ class gs_github_pull_request(GsWindowCommand, git_mixins.GithubRemotesMixin): """ - Display open pull requests on the base repo. When a pull request is selected, + Display pull requests on the base repo. When a pull request is selected, allow the user to 1) checkout the PR as detached HEAD, 2) checkout the PR as a local branch, 3) view the PR's diff, or 4) open the PR in the browser. + + By default, all "open" pull requests are displayed. This can be customized + using the `query` arg which is of the same query format as in the Web UI of + Github. Note that "repo:", "type:", and "state:" are prefilled if omitted. """ @on_worker - def run(self): + def run(self, query=""): self.remotes = self.get_remotes() self.base_remote_name = self.get_integrated_remote_name(self.remotes) self.base_remote_url = self.remotes[self.base_remote_name] - self.base_remote = github.parse_remote(self.base_remote_url) - self.pull_requests = github.get_pull_requests(self.base_remote) + self.base_remote = repository = github.parse_remote(self.base_remote_url) + + query_ = " ".join(filter_(( + f"repo:{repository.owner}/{repository.repo}" if "repo:" not in query else None, + "type:pr" if "type:" not in query else None, + "state:open" if "state:" not in query else None, + query.strip() + ))) + self.pull_requests = github.search_pull_requests(self.base_remote, query_) pp = show_paginated_panel( self.pull_requests, @@ -67,7 +79,7 @@ def on_select_pr(self, pr): if not pr: return - self.pr = pr + self.pr_ = run_as_future(github.get_pull_request, pr["number"], self.base_remote) self.window.show_quick_panel( ["Checkout as detached HEAD.", "Checkout as local branch.", @@ -81,6 +93,15 @@ def on_select_action(self, idx): if idx == -1: return + # Note that the request starts in `on_select_pr`. So the actual wait time includes the + # time we wait for the user to take action. + timeout = 4.0 + try: + self.pr = self.pr_.result(timeout) + except TimeoutError: + self.window.status_message(f"Timeout: could not fetch the PR details within {timeout} seconds.") + return + owner = self.pr["head"]["repo"]["owner"]["login"] if owner == self.base_remote.owner: diff --git a/github/github.py b/github/github.py index a524730bb..9d0a8793f 100644 --- a/github/github.py +++ b/github/github.py @@ -120,7 +120,9 @@ def get_api_fqdn(github_repo): return True, github_repo.fqdn -def github_api_url(api_url_template, repository, **kwargs): +def github_api_url( + api_url_template: str, repository: GitHubRepo, **kwargs: dict[str, str] +) -> tuple[str, str]: """ Construct a github URL to query using the given url template string, and a github.GitHubRepo instance, and optionally query parameters @@ -151,7 +153,7 @@ def validate_response(response, method="GET"): action=action, payload=response.payload)) -def query_github(api_url_template, github_repo): +def query_github(api_url_template: str, github_repo: GitHubRepo): """ Takes a URL template that takes `owner` and `repo` template variables and as a GitHub repo object. Do a GET for the provided URL and return @@ -169,13 +171,16 @@ def query_github(api_url_template, github_repo): get_repo_data = partial(query_github, "/repos/{owner}/{repo}") -def iteratively_query_github(api_url_template, github_repo): +def iteratively_query_github( + api_url_template: str, github_repo: GitHubRepo, query: dict = {}, yield_: str = None +): """ Like `query_github` but return a generator by repeatedly iterating until no link to next page. """ - fqdn, path = github_api_url(api_url_template, github_repo, - per_page=GITHUB_PER_PAGE_MAX) + default_query = {"per_page": GITHUB_PER_PAGE_MAX} + query_ = {**default_query, **query} + fqdn, path = github_api_url(api_url_template, github_repo, **query_) auth = (github_repo.token, "x-oauth-basic") if github_repo.token else None response = None @@ -198,8 +203,10 @@ def iteratively_query_github(api_url_template, github_repo): validate_response(response) if response.payload: - for item in response.payload: - yield item + if yield_: + yield from response.payload[yield_] + else: + yield from response.payload else: break @@ -210,6 +217,28 @@ def iteratively_query_github(api_url_template, github_repo): get_pull_requests = partial(iteratively_query_github, "/repos/{owner}/{repo}/pulls") +def search_pull_requests(repository: GitHubRepo, q: str): + return iteratively_query_github("/search/issues", repository, query={"q": q}, yield_="items") + + +def get_pull_request(nr: str | int, github_repo: GitHubRepo): + return get_from_github(f"/repos/{{owner}}/{{repo}}/pulls/{nr}", github_repo) + + +def get_from_github(api_url_template: str, github_repo: GitHubRepo): + fqdn, path = github_api_url(api_url_template, github_repo) + auth = (github_repo.token, "x-oauth-basic") if github_repo.token else None + + response = interwebs.get( + fqdn, 443, path, https=True, auth=auth, + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + }) + validate_response(response) + return response.payload + + def post_to_github(api_url_template, github_repo, payload=None): """ Takes a URL template that takes `owner` and `repo` template variables