diff --git a/lazy_github/lib/github_v2/auth.py b/lazy_github/lib/github_v2/auth.py index 2aa2857..63ce8df 100644 --- a/lazy_github/lib/github_v2/auth.py +++ b/lazy_github/lib/github_v2/auth.py @@ -31,40 +31,43 @@ class GithubAuthenticationRequired(Exception): pass -def get_device_code() -> DeviceCodeResponse: +async def get_device_code() -> DeviceCodeResponse: """ Authenticates this device with the Github API. This will require the user to go enter the provided device code on the Github UI to authenticate the LazyGithub app. """ - response = ( - httpx.post( + async with httpx.AsyncClient() as client: + response = await client.post( "https://github.com/login/device/code", data={"client_id": LAZY_GITHUB_CLIENT_ID}, headers={"Accept": "application/json"}, ) - .raise_for_status() - .json() - ) - expires_at = time.time() + response["expires_in"] + + response.raise_for_status() + body = response.json() + expires_at = time.time() + body["expires_in"] return DeviceCodeResponse( - response["device_code"], - response["verification_uri"], - response["user_code"], - response["interval"], + body["device_code"], + body["verification_uri"], + body["user_code"], + body["interval"], expires_at, ) -def get_access_token(device_code: DeviceCodeResponse) -> AccessTokenResponse: +async def get_access_token(device_code: DeviceCodeResponse) -> AccessTokenResponse: """Given a device code, retrieves the oauth access token that can be used to send requests to the GIthub API""" - access_token_res = httpx.post( - "https://github.com/login/oauth/access_token", - data={ - "client_id": LAZY_GITHUB_CLIENT_ID, - "grant_type": DEVICE_CODE_GRANT_TYPE, - "device_code": device_code.device_code, - }, - ).raise_for_status() + async with httpx.AsyncClient() as client: + # TODO: This should specify an accept + access_token_res = await client.post( + "https://github.com/login/oauth/access_token", + data={ + "client_id": LAZY_GITHUB_CLIENT_ID, + "grant_type": DEVICE_CODE_GRANT_TYPE, + "device_code": device_code.device_code, + }, + ) + access_token_res.raise_for_status() pairs = access_token_res.text.split("&") access_token_data = dict(pair.split("=") for pair in pairs) return AccessTokenResponse( diff --git a/lazy_github/lib/github_v2/client.py b/lazy_github/lib/github_v2/client.py index 02f6ace..76c3e18 100644 --- a/lazy_github/lib/github_v2/client.py +++ b/lazy_github/lib/github_v2/client.py @@ -1,5 +1,3 @@ -from functools import cached_property - import httpx from lazy_github.lib.config import Config @@ -18,7 +16,6 @@ def headers_with_auth_accept(self, accept: str = JSON_CONTENT_ACCEPT_TYPE) -> di """Helper function to build a request with specific headers""" return {"Accept": accept, "Authorization": f"Bearer {self.access_token}"} - @cached_property async def user(self) -> User: """Returns the authed user for this client""" if self._user is None: diff --git a/lazy_github/lib/github_v2/issues.py b/lazy_github/lib/github_v2/issues.py index aa95939..138f7ad 100644 --- a/lazy_github/lib/github_v2/issues.py +++ b/lazy_github/lib/github_v2/issues.py @@ -1,3 +1,4 @@ +from functools import partial from typing import Literal from lazy_github.lib.github_v2.client import GithubClient @@ -8,7 +9,7 @@ async def _list(client: GithubClient, repo: Repository, state: IssueStateFilter) -> list[Issue]: query_params = {"state": state} - user = await client.user + user = await client.user() response = await client.get( f"/repos/{user.login}/{repo.name}/issues", headers=client.headers_with_auth_accept(), params=query_params ) @@ -21,6 +22,11 @@ async def _list(client: GithubClient, repo: Repository, state: IssueStateFilter) return result +list_open_issues = partial(_list, state="open") +list_closed_issues = partial(_list, state="closed") +list_all_issues = partial(_list, state="all") + + if __name__ == "__main__": import asyncio diff --git a/lazy_github/lib/github_v2/pull_requests.py b/lazy_github/lib/github_v2/pull_requests.py index ed1dd66..d1b2063 100644 --- a/lazy_github/lib/github_v2/pull_requests.py +++ b/lazy_github/lib/github_v2/pull_requests.py @@ -1,13 +1,8 @@ -from typing import Self - from lazy_github.lib.github_v2.client import GithubClient +from lazy_github.lib.github_v2.issues import list_all_issues +from lazy_github.models.core import PullRequest, Repository -class PullRequest: - def __init__(self, client: GithubClient, raw_data) -> None: - self.client = client - self._raw_data = raw_data - - @classmethod - async def list_for_repo(cls, client: GithubClient, repo: str) -> list[Self]: - return [cls(client, {})] +async def list_for_repo(client: GithubClient, repo: Repository) -> list[PullRequest]: + issues = await list_all_issues(client, repo) + return [i for i in issues if isinstance(i, PullRequest)] diff --git a/lazy_github/lib/github_v2/repositories.py b/lazy_github/lib/github_v2/repositories.py index cb9c489..f252b1b 100644 --- a/lazy_github/lib/github_v2/repositories.py +++ b/lazy_github/lib/github_v2/repositories.py @@ -19,7 +19,7 @@ async def _list( ) -> list[Repository]: """Retrieves Github repos matching the specified criteria""" query_params = {"type": repo_types, "direction": direction, "sort": sort, "page": page, "per_page": per_page} - user = await client.user + user = await client.user() response = await client.get( f"/users/{user.login}/repos", headers=client.headers_with_auth_accept(), params=query_params ) diff --git a/lazy_github/lib/messages.py b/lazy_github/lib/messages.py index 9757706..c9f64a9 100644 --- a/lazy_github/lib/messages.py +++ b/lazy_github/lib/messages.py @@ -1,7 +1,9 @@ -from github.PullRequest import PullRequest -from github.Repository import Repository +from functools import cached_property + from textual.message import Message +from lazy_github.models.core import Issue, PullRequest, Repository + class RepoSelected(Message): """ @@ -24,3 +26,26 @@ class PullRequestSelected(Message): def __init__(self, pr: PullRequest) -> None: self.pr = pr super().__init__() + + +class IssuesAndPullRequestsFetched(Message): + """ + Since issues and pull requests are both represented on the Github API as issues, we want to pull issues once and + then send that message to both sections of the UI. + """ + + def __init__(self, issues_and_pull_requests: list[Issue]) -> None: + self.issues_and_pull_requests = issues_and_pull_requests + super().__init__() + + @cached_property + def pull_requests(self) -> list[PullRequest]: + return [pr for pr in self.issues_and_pull_requests if isinstance(pr, PullRequest)] + + @cached_property + def issues(self) -> list[Issue]: + return [ + issue + for issue in self.issues_and_pull_requests + if isinstance(issue, Issue) and not isinstance(issue, PullRequest) + ] diff --git a/lazy_github/ui/app.py b/lazy_github/ui/app.py index f0aafac..08ef548 100644 --- a/lazy_github/ui/app.py +++ b/lazy_github/ui/app.py @@ -2,6 +2,9 @@ from textual.app import App import lazy_github.lib.github as g +from lazy_github.lib.config import Config +from lazy_github.lib.github_v2.auth import token +from lazy_github.lib.github_v2.client import GithubClient from lazy_github.ui.screens.auth import AuthenticationModal from lazy_github.ui.screens.primary import LazyGithubMainScreen @@ -13,15 +16,17 @@ class LazyGithub(App): ] async def authenticate_with_github(self): + config = Config.load_config() try: - _github = g.github_client() - self.push_screen(LazyGithubMainScreen()) + access_token = token() + client = GithubClient(config, access_token) + self.push_screen(LazyGithubMainScreen(client)) except g.GithubAuthenticationRequired: log("Triggering auth with github") self.push_screen(AuthenticationModal(id="auth-modal")) - def on_ready(self): - self.run_worker(self.authenticate_with_github) + async def on_ready(self): + await self.authenticate_with_github() app = LazyGithub() diff --git a/lazy_github/ui/screens/auth.py b/lazy_github/ui/screens/auth.py index 47b7afc..2d41c9d 100644 --- a/lazy_github/ui/screens/auth.py +++ b/lazy_github/ui/screens/auth.py @@ -8,7 +8,9 @@ from textual.widget import Widget from textual.widgets import Footer -import lazy_github.lib.github as g +import lazy_github.lib.github_v2.auth as auth +from lazy_github.lib.config import Config +from lazy_github.lib.github_v2.client import GithubClient from lazy_github.ui.screens.primary import LazyGithubMainScreen @@ -45,8 +47,8 @@ def compose(self) -> ComposeResult: yield Footer() @work - async def check_access_token(self, device_code: g.DeviceCodeResponse): - access_token = g.get_access_token(device_code) + async def check_access_token(self, device_code: auth.DeviceCodeResponse): + access_token = await auth.get_access_token(device_code) match access_token.error: case "authorization_pending": log("Continuing to wait for auth...") @@ -67,14 +69,14 @@ async def check_access_token(self, device_code: g.DeviceCodeResponse): self.access_token_timer.stop() case _: log("Successfully authenticated!") - g.save_access_token(access_token) + auth.save_access_token(access_token) self.access_token_timer.stop() - self.app.switch_screen(LazyGithubMainScreen()) + self.app.switch_screen(LazyGithubMainScreen(GithubClient(Config.load_config(), auth.token()))) @work async def get_device_token(self): log("Attempting to get device code...") - device_code = g.get_device_code() + device_code = await auth.get_device_code() log(f"Device code: {device_code}") self.query_one(UserTokenDisplay).user_code = device_code.user_code diff --git a/lazy_github/ui/screens/primary.py b/lazy_github/ui/screens/primary.py index ee52acc..93bd572 100644 --- a/lazy_github/ui/screens/primary.py +++ b/lazy_github/ui/screens/primary.py @@ -6,7 +6,9 @@ from textual.widget import Widget from textual.widgets import Footer, TabbedContent -from lazy_github.lib.messages import PullRequestSelected, RepoSelected +from lazy_github.lib.github_v2.client import GithubClient +from lazy_github.lib.github_v2.issues import list_all_issues +from lazy_github.lib.messages import IssuesAndPullRequestsFetched, PullRequestSelected, RepoSelected from lazy_github.ui.widgets.actions import ActionsContainer from lazy_github.ui.widgets.command_log import CommandLogSection from lazy_github.ui.widgets.common import LazyGithubContainer @@ -86,9 +88,13 @@ class SelectionsPane(Container): } """ + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.client = client + def compose(self) -> ComposeResult: - yield ReposContainer(id="repos") - yield PullRequestsContainer(id="pull_requests") + yield ReposContainer(self.client, id="repos") + yield PullRequestsContainer(self.client, id="pull_requests") yield IssuesContainer(id="issues") yield ActionsContainer(id="actions") @@ -105,12 +111,18 @@ def actions(self) -> ActionsContainer: return self.query_one("#actions", ActionsContainer) async def on_repo_selected(self, message: RepoSelected) -> None: - self.pull_requests.post_message(message) - self.issues.post_message(message) - self.actions.post_message(message) + # self.actions.post_message(message) + issues_and_pull_requests = await list_all_issues(self.client, message.repo) + issue_and_pr_message = IssuesAndPullRequestsFetched(issues_and_pull_requests) + self.pull_requests.post_message(issue_and_pr_message) + self.issues.post_message(issue_and_pr_message) class SelectionDetailsPane(Container): + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.client = client + def compose(self) -> ComposeResult: yield SelectionDetailsContainer(id="selection_details") yield CommandLogSection() @@ -126,15 +138,19 @@ class MainViewPane(Container): ("6", "focus_section('LazyGithubCommandLog')"), ] + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.client = client + def action_focus_section(self, selector: str) -> None: self.query_one(selector).focus() def compose(self) -> ComposeResult: - yield SelectionsPane() - yield SelectionDetailsPane() + yield SelectionsPane(self.client) + yield SelectionDetailsPane(self.client) def on_pull_request_selected(self, message: PullRequestSelected) -> None: - log(f"raw PR = {message.pr.raw_data}") + log(f"PR = {message.pr}") tabbed_content = self.query_one("#selection_detail_tabs", TabbedContent) tabbed_content.clear_panes() tabbed_content.add_pane(PrOverviewTabPane(message.pr)) @@ -146,8 +162,12 @@ def on_pull_request_selected(self, message: PullRequestSelected) -> None: class LazyGithubMainScreen(Screen): BINDINGS = [("r", "refresh_repos", "Refresh global repo state")] + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.client = client + def compose(self): with Container(): yield LazyGithubStatusSummary() - yield MainViewPane() + yield MainViewPane(self.client) yield LazyGithubFooter() diff --git a/lazy_github/ui/widgets/issues.py b/lazy_github/ui/widgets/issues.py index fb231e7..ea60e79 100644 --- a/lazy_github/ui/widgets/issues.py +++ b/lazy_github/ui/widgets/issues.py @@ -1,9 +1,9 @@ from typing import Dict -from github.Issue import Issue from textual.app import ComposeResult -from lazy_github.lib.messages import RepoSelected +from lazy_github.lib.messages import IssuesAndPullRequestsFetched +from lazy_github.models.core import Issue from lazy_github.ui.widgets.common import LazyGithubContainer, LazyGithubDataTable @@ -32,16 +32,13 @@ def on_mount(self) -> None: self.number_column_index = self.table.get_column_index("number") self.title_column_index = self.table.get_column_index("title") - async def on_repo_selected(self, message: RepoSelected) -> None: + async def on_issues_and_pull_requests_fetched(self, message: IssuesAndPullRequestsFetched) -> None: message.stop() self.table.clear() self.issues = {} - issues = message.repo.get_issues(state="all", sort="updated", direction="desc") + rows = [] - for issue in issues: - # TODO: Currently, this also includes PRs because the Github API classifies all PRs as issues. The PyGithub - # library makes this even more problematic because there seemingly isn't a way to distinguish between an - # issue and a PR without making an additional API request. This is quite slow >:( + for issue in message.issues: self.issues[issue.number] = issue rows.append((issue.state, issue.number, issue.user.login, issue.title)) self.table.add_rows(rows) diff --git a/lazy_github/ui/widgets/pull_requests.py b/lazy_github/ui/widgets/pull_requests.py index cd4b67c..4a2a0d1 100644 --- a/lazy_github/ui/widgets/pull_requests.py +++ b/lazy_github/ui/widgets/pull_requests.py @@ -1,18 +1,16 @@ -from typing import Dict, Iterable +from typing import Dict -from github.IssueComment import IssueComment -from github.PullRequest import PullRequest -from github.PullRequestComment import PullRequestComment -from github.PullRequestReview import PullRequestReview from textual import on, work from textual.app import ComposeResult from textual.containers import ScrollableContainer from textual.coordinate import Coordinate -from textual.widgets import Label, ListItem, ListView, Markdown, RichLog, Rule, TabPane +from textual.widgets import Label, ListView, Markdown, RichLog, Rule, TabPane -import lazy_github.lib.github as g -from lazy_github.lib.messages import PullRequestSelected, RepoSelected +import lazy_github.lib.github_v2.pull_requests as pr_api +from lazy_github.lib.github_v2.client import GithubClient +from lazy_github.lib.messages import IssuesAndPullRequestsFetched, PullRequestSelected from lazy_github.lib.string_utils import bold, link, pluralize +from lazy_github.models.core import PullRequest from lazy_github.ui.widgets.command_log import log_event from lazy_github.ui.widgets.common import LazyGithubContainer, LazyGithubDataTable @@ -22,10 +20,13 @@ class PullRequestsContainer(LazyGithubContainer): This container includes the primary datatable for viewing pull requests on the UI. """ - pull_requests: Dict[int, PullRequest] = {} - status_column_index = -1 - number_column_index = -1 - title_column_index = -1 + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + self.client = client + self.pull_requests: Dict[int, PullRequest] = {} + self.status_column_index = -1 + self.number_column_index = -1 + self.title_column_index = -1 + super().__init__(*args, **kwargs) def compose(self) -> ComposeResult: self.border_title = "[2] Pull Requests" @@ -46,13 +47,13 @@ def on_mount(self) -> None: self.number_column_index = self.table.get_column_index("number") self.title_column_index = self.table.get_column_index("title") - async def on_repo_selected(self, message: RepoSelected) -> None: + async def on_issues_and_pull_requests_fetched(self, message: IssuesAndPullRequestsFetched) -> None: message.stop() self.table.clear() self.pull_requests = {} - pull_requests = message.repo.get_pulls(state="all", sort="updated", direction="desc") + rows = [] - for pr in pull_requests: + for pr in message.pull_requests: self.pull_requests[pr.number] = pr rows.append((pr.state, pr.number, pr.user.login, pr.title)) self.table.add_rows(rows) @@ -80,7 +81,7 @@ def __init__(self, pr: PullRequest) -> None: super().__init__("Overview", id="overview_pane") self.pr = pr - def compose(self) -> ComposeResult: + def _old_compose(self) -> ComposeResult: pr_link = link(f"(#{self.pr.number})", self.pr.html_url) user_link = link(self.pr.user.login, self.pr.user.html_url) merge_from = bold(f"{self.pr.head.user.login}:{self.pr.head.ref}") @@ -120,7 +121,7 @@ def __init__(self, pr: PullRequest) -> None: super().__init__("Diff", id="diff_pane") self.pr = pr - def compose(self) -> ComposeResult: + def _old_compose(self) -> ComposeResult: with ScrollableContainer(): yield RichLog(id="diff_contents", highlight=True) @@ -128,10 +129,11 @@ def compose(self) -> ComposeResult: async def write_diff(self, diff: str) -> None: self.query_one("#diff_contents", RichLog).write(diff) - @work(thread=True) - def fetch_diff(self): - diff = g.get_diff(self.pr) - self.write_diff(diff) + @work + async def fetch_diff(self): + pass + # diff = g.get_diff(self.pr) + # self.write_diff(diff) def on_mount(self) -> None: self.fetch_diff() @@ -152,20 +154,21 @@ def conversation_elements(self) -> ListView: @work async def render_conversation( self, - pr_comments: Iterable[IssueComment], - reviews: Iterable[PullRequestReview], - review_comments: Iterable[PullRequestComment], + # pr_comments: Iterable[IssueComment], + # reviews: Iterable[PullRequestReview], + # review_comments: Iterable[PullRequestComment], ) -> None: - conversation_elements = self.conversation_elements + pass + # conversation_elements = self.conversation_elements # reviews_by_id = {r.id: r for r in reviews} # review_comments_by_id = {rc.id: rc for rc in review_comments} # pr_comments_by_id = {prc.id: prc for prc in pr_comments} - for review in review_comments: - conversation_elements.append(ListItem(Label(f"{review.user.login}\n{review.body}"))) + # for review in review_comments: + # conversation_elements.append(ListItem(Label(f"{review.user.login}\n{review.body}"))) - @work(thread=True) - def fetch_conversation(self): + @work + async def fetch_conversation(self): # TODO: Okay, so the review API in Github is weird. There are 3 APIs we might need to leverage here. # # 1. The conversation API, which contains comments that happen separately from a review. Unclear if these @@ -174,10 +177,12 @@ def fetch_conversation(self): # necessary to setup a list of distinct threads of a review conversation that are happening. # 3. The review comments API, which pulls comments for a particular review. It doesn't look like the reviews API # actually has the full conversation associated with a review, so might need to query this as well :( - comments = self.pr.get_issue_comments() - reviews = self.pr.get_reviews() - review_comments = self.pr.get_review_comments() - self.render_conversation(comments, reviews, review_comments) + pass + # comments = self.pr.get_issue_comments() + # reviews = self.pr.get_reviews() + # review_comments = self.pr.get_review_comments() + # self.render_conversation(comments, reviews, review_comments) def on_mount(self) -> None: - self.fetch_conversation() + pass + # self.fetch_conversation() diff --git a/lazy_github/ui/widgets/repositories.py b/lazy_github/ui/widgets/repositories.py index 714b7e1..f01ab52 100644 --- a/lazy_github/ui/widgets/repositories.py +++ b/lazy_github/ui/widgets/repositories.py @@ -1,14 +1,15 @@ from typing import Dict, Iterable -from github.Repository import Repository from textual import on, work from textual.app import ComposeResult from textual.coordinate import Coordinate -import lazy_github.lib.github as g +import lazy_github.lib.github_v2.repositories as repos_api from lazy_github.lib.config import Config from lazy_github.lib.constants import IS_FAVORITED, favorite_string, private_string +from lazy_github.lib.github_v2.client import GithubClient from lazy_github.lib.messages import RepoSelected +from lazy_github.models.core import Repository from lazy_github.ui.widgets.command_log import log_event from lazy_github.ui.widgets.common import LazyGithubContainer, LazyGithubDataTable @@ -19,11 +20,14 @@ class ReposContainer(LazyGithubContainer): ("enter", "select"), ] - repos: Dict[str, Repository] = {} - favorite_column_index = -1 - owner_column_index = 1 - name_column_index = 1 - private_column_index = 1 + def __init__(self, client: GithubClient, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.client = client + self.repos: Dict[str, Repository] = {} + self.favorite_column_index = -1 + self.owner_column_index = 1 + self.name_column_index = 1 + self.private_column_index = 1 def compose(self) -> ComposeResult: self.border_title = "[1] Repositories" @@ -72,10 +76,9 @@ async def add_repos_to_table(self, repos: Iterable[Repository]) -> None: if config.repositories.favorites: self.table.sort("favorite") - @work(thread=True) - def load_repos(self) -> None: - user = g.github_client().get_user() - repos = user.get_repos() + @work + async def load_repos(self) -> None: + repos = await repos_api.list_all(self.client) self.add_repos_to_table(repos) async def action_toggle_favorite_repo(self):