lazy-github 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. lazy_github-0.1/LICENSE +21 -0
  2. lazy_github-0.1/PKG-INFO +67 -0
  3. lazy_github-0.1/README.md +25 -0
  4. lazy_github-0.1/lazy_github/__main__.py +9 -0
  5. lazy_github-0.1/lazy_github/lib/config.py +66 -0
  6. lazy_github-0.1/lazy_github/lib/constants.py +27 -0
  7. lazy_github-0.1/lazy_github/lib/github/auth.py +101 -0
  8. lazy_github-0.1/lazy_github/lib/github/client.py +32 -0
  9. lazy_github-0.1/lazy_github/lib/github/issues.py +40 -0
  10. lazy_github-0.1/lazy_github/lib/github/pull_requests.py +111 -0
  11. lazy_github-0.1/lazy_github/lib/github/repositories.py +30 -0
  12. lazy_github-0.1/lazy_github/lib/messages.py +51 -0
  13. lazy_github-0.1/lazy_github/lib/string_utils.py +16 -0
  14. lazy_github-0.1/lazy_github/models/github.py +128 -0
  15. lazy_github-0.1/lazy_github/ui/app.py +28 -0
  16. lazy_github-0.1/lazy_github/ui/screens/auth.py +92 -0
  17. lazy_github-0.1/lazy_github/ui/screens/new_comment.py +143 -0
  18. lazy_github-0.1/lazy_github/ui/screens/primary.py +189 -0
  19. lazy_github-0.1/lazy_github/ui/widgets/actions.py +14 -0
  20. lazy_github-0.1/lazy_github/ui/widgets/command_log.py +36 -0
  21. lazy_github-0.1/lazy_github/ui/widgets/common.py +26 -0
  22. lazy_github-0.1/lazy_github/ui/widgets/conversations.py +116 -0
  23. lazy_github-0.1/lazy_github/ui/widgets/issues.py +44 -0
  24. lazy_github-0.1/lazy_github/ui/widgets/pull_requests.py +228 -0
  25. lazy_github-0.1/lazy_github/ui/widgets/repositories.py +105 -0
  26. lazy_github-0.1/lazy_github.egg-info/PKG-INFO +67 -0
  27. lazy_github-0.1/lazy_github.egg-info/SOURCES.txt +31 -0
  28. lazy_github-0.1/lazy_github.egg-info/dependency_links.txt +1 -0
  29. lazy_github-0.1/lazy_github.egg-info/entry_points.txt +2 -0
  30. lazy_github-0.1/lazy_github.egg-info/requires.txt +6 -0
  31. lazy_github-0.1/lazy_github.egg-info/top_level.txt +1 -0
  32. lazy_github-0.1/pyproject.toml +45 -0
  33. lazy_github-0.1/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Christopher Chapline
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.1
2
+ Name: lazy-github
3
+ Version: 0.1
4
+ Summary: A terminal UI for interacting with Github
5
+ Author-email: Chris Chapline <gizmo385@users.noreply.github.com>
6
+ Maintainer-email: Chris Chapline <gizmo385@users.noreply.github.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2024 Christopher Chapline
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Classifier: Development Status :: 4 - Beta
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Requires-Python: >=3.11
34
+ Description-Content-Type: text/markdown
35
+ License-File: LICENSE
36
+ Requires-Dist: PyGithub
37
+ Requires-Dist: httpx
38
+ Requires-Dist: hishel
39
+ Requires-Dist: pydantic
40
+ Requires-Dist: textual
41
+ Requires-Dist: textual-dev
42
+
43
+ # LazyGithub
44
+
45
+ This is a **WIP** terminal UI client for interacting with [GitHub](https://github.com). It draws heavy
46
+ inspiration from the [lazygit](https://github.com/jesseduffield/lazygit) project and uses
47
+ [Textual](https://textual.textualize.io/) to drive the terminal UI interactions. Currently, it
48
+ supports the following:
49
+
50
+ - Listing the repositories associated with your account
51
+ - Listing the issues and pull requests on those repositories
52
+ - Listing the details, diff, and reviews on any of those pull requests
53
+
54
+ Planned features:
55
+ - Local caching, improving reload times and making it easier to use within a terminal or editor
56
+ environment.
57
+ - A more wholeistic summary view for the currently selected repository
58
+ - The ability to list, view, and trigger actions on a repository
59
+ - More fleshed out PR interactions, including commenting and eventually submitting full PR reviews
60
+ from within your terminal.
61
+ - Detailed issue views, including conversation participation
62
+
63
+
64
+ ## Running Locally
65
+
66
+ I'm not currently automatically pushing LazyGithub up to [PyPi](https://pypi.org/) (although I plan
67
+ to in the future), so for the time being you can run this locally via the `./start.sh` script.
@@ -0,0 +1,25 @@
1
+ # LazyGithub
2
+
3
+ This is a **WIP** terminal UI client for interacting with [GitHub](https://github.com). It draws heavy
4
+ inspiration from the [lazygit](https://github.com/jesseduffield/lazygit) project and uses
5
+ [Textual](https://textual.textualize.io/) to drive the terminal UI interactions. Currently, it
6
+ supports the following:
7
+
8
+ - Listing the repositories associated with your account
9
+ - Listing the issues and pull requests on those repositories
10
+ - Listing the details, diff, and reviews on any of those pull requests
11
+
12
+ Planned features:
13
+ - Local caching, improving reload times and making it easier to use within a terminal or editor
14
+ environment.
15
+ - A more wholeistic summary view for the currently selected repository
16
+ - The ability to list, view, and trigger actions on a repository
17
+ - More fleshed out PR interactions, including commenting and eventually submitting full PR reviews
18
+ from within your terminal.
19
+ - Detailed issue views, including conversation participation
20
+
21
+
22
+ ## Running Locally
23
+
24
+ I'm not currently automatically pushing LazyGithub up to [PyPi](https://pypi.org/) (although I plan
25
+ to in the future), so for the time being you can run this locally via the `./start.sh` script.
@@ -0,0 +1,9 @@
1
+ from lazy_github.ui.app import app
2
+
3
+
4
+ def main():
5
+ app.run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,66 @@
1
+ import json
2
+ from contextlib import contextmanager
3
+ from datetime import timedelta
4
+ from pathlib import Path
5
+ from typing import Generator, List, Literal, Self
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from lazy_github.lib.constants import CONFIG_FOLDER
10
+
11
+ _CONFIG_FILE_LOCATION = CONFIG_FOLDER / "config.json"
12
+
13
+ PR_STATE_FILTER = Literal["all"] | Literal["open"] | Literal["closed"]
14
+
15
+
16
+ class ApiConfig(BaseModel):
17
+ base_url: str = "https://api.github.com"
18
+
19
+
20
+ class PullRequestSettings(BaseModel):
21
+ state_filter: PR_STATE_FILTER = "all"
22
+
23
+
24
+ class RepositorySettings(BaseModel):
25
+ favorites: List[str] = []
26
+
27
+
28
+ class CacheSettings(BaseModel):
29
+ cache_directory: Path = CONFIG_FOLDER / ".cache"
30
+ default_ttl: int = int(timedelta(minutes=10).total_seconds())
31
+ list_repos_ttl: int = int(timedelta(days=1).total_seconds())
32
+ list_issues_ttl: int = int(timedelta(hours=1).total_seconds())
33
+
34
+
35
+ class AppearenceSettings(BaseModel):
36
+ dark_mode: bool = True
37
+
38
+
39
+ class Config(BaseModel):
40
+ appearence: AppearenceSettings = AppearenceSettings()
41
+ repositories: RepositorySettings = RepositorySettings()
42
+ pull_requests: PullRequestSettings = PullRequestSettings()
43
+ cache: CacheSettings = CacheSettings()
44
+ api: ApiConfig = ApiConfig()
45
+
46
+ @classmethod
47
+ def load_config(cls) -> Self:
48
+ if _CONFIG_FILE_LOCATION.exists():
49
+ return cls(**json.loads(_CONFIG_FILE_LOCATION.read_text()))
50
+ else:
51
+ return cls()
52
+
53
+ def save(self) -> None:
54
+ CONFIG_FOLDER.mkdir(parents=True, exist_ok=True)
55
+ _CONFIG_FILE_LOCATION.write_text(self.model_dump_json(indent=4))
56
+
57
+ @classmethod
58
+ @contextmanager
59
+ def to_edit(cls) -> Generator[Self, None, None]:
60
+ current_config = cls.load_config()
61
+ yield current_config
62
+ current_config.save()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ print(Config.load_config().model_dump_json(indent=4))
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+
3
+ # Content types
4
+ DIFF_CONTENT_ACCEPT_TYPE = "application/vnd.github.diff"
5
+ JSON_CONTENT_ACCEPT_TYPE = "application/vnd.github+json"
6
+
7
+ # App access information
8
+ LAZY_GITHUB_CLIENT_ID = "Iv23limdG8Bl3Cu5FOcT"
9
+ DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
10
+
11
+ # Symbols used in various UI tables
12
+ IS_FAVORITED = "[green]★[/green]"
13
+ IS_NOT_FAVORITED = "☆"
14
+ IS_PRIVATE = "✔"
15
+ IS_PUBLIC = "✘"
16
+
17
+ CONFIG_FOLDER = Path.home() / ".config/lazy-github"
18
+
19
+
20
+ def favorite_string(favorite: bool) -> str:
21
+ """Helper function to return the right string to indicate if something is favorited"""
22
+ return IS_FAVORITED if favorite else IS_NOT_FAVORITED
23
+
24
+
25
+ def private_string(private: bool) -> str:
26
+ """Helper function to return the right string to indicate if something is private"""
27
+ return IS_PRIVATE if private else IS_PUBLIC
@@ -0,0 +1,101 @@
1
+ import time
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ import httpx
6
+
7
+ from lazy_github.lib.constants import CONFIG_FOLDER
8
+ from lazy_github.lib.constants import DEVICE_CODE_GRANT_TYPE, LAZY_GITHUB_CLIENT_ID
9
+
10
+ # Auth and client globals
11
+ _AUTHENTICATION_CACHE_LOCATION = CONFIG_FOLDER / "auth.text"
12
+ _AUTH_TOKEN: Optional[str] = None
13
+
14
+
15
+ @dataclass
16
+ class DeviceCodeResponse:
17
+ device_code: str
18
+ verification_uri: str
19
+ user_code: str
20
+ polling_interval: int
21
+ expires_at: int
22
+
23
+
24
+ @dataclass
25
+ class AccessTokenResponse:
26
+ token: Optional[str]
27
+ error: Optional[str]
28
+
29
+
30
+ class GithubAuthenticationRequired(Exception):
31
+ pass
32
+
33
+
34
+ async def get_device_code() -> DeviceCodeResponse:
35
+ """
36
+ Authenticates this device with the Github API. This will require the user to go enter the provided device code on
37
+ the Github UI to authenticate the LazyGithub app.
38
+ """
39
+ async with httpx.AsyncClient() as client:
40
+ response = await client.post(
41
+ "https://github.com/login/device/code",
42
+ data={"client_id": LAZY_GITHUB_CLIENT_ID},
43
+ headers={"Accept": "application/json"},
44
+ )
45
+
46
+ response.raise_for_status()
47
+ body = response.json()
48
+ expires_at = time.time() + body["expires_in"]
49
+ return DeviceCodeResponse(
50
+ body["device_code"],
51
+ body["verification_uri"],
52
+ body["user_code"],
53
+ body["interval"],
54
+ expires_at,
55
+ )
56
+
57
+
58
+ async def get_access_token(device_code: DeviceCodeResponse) -> AccessTokenResponse:
59
+ """Given a device code, retrieves the oauth access token that can be used to send requests to the GIthub API"""
60
+ async with httpx.AsyncClient() as client:
61
+ # TODO: This should specify an accept
62
+ access_token_res = await client.post(
63
+ "https://github.com/login/oauth/access_token",
64
+ data={
65
+ "client_id": LAZY_GITHUB_CLIENT_ID,
66
+ "grant_type": DEVICE_CODE_GRANT_TYPE,
67
+ "device_code": device_code.device_code,
68
+ },
69
+ )
70
+ access_token_res.raise_for_status()
71
+ pairs = access_token_res.text.split("&")
72
+ access_token_data = dict(pair.split("=") for pair in pairs)
73
+ return AccessTokenResponse(
74
+ access_token_data.get("access_token"),
75
+ access_token_data.get("error"),
76
+ )
77
+
78
+
79
+ def save_access_token(access_token: AccessTokenResponse) -> None:
80
+ """Writes the returned access token to the config location"""
81
+ if not access_token.token:
82
+ raise ValueError("Invalid access token response! Cannot save")
83
+
84
+ # Create the parent directories for our cache if it's present
85
+ _AUTHENTICATION_CACHE_LOCATION.parent.mkdir(parents=True, exist_ok=True)
86
+ _AUTHENTICATION_CACHE_LOCATION.write_text(access_token.token)
87
+
88
+
89
+ def token() -> str:
90
+ """
91
+ Helper function which loads the token from the file on disk. If the file does not exist, it raises a
92
+ GithubAuthenticationRequired exception that the caller should handle by triggering the auth flow
93
+ """
94
+ global _AUTH_TOKEN
95
+ if _AUTH_TOKEN is not None:
96
+ return _AUTH_TOKEN
97
+
98
+ if not _AUTHENTICATION_CACHE_LOCATION.exists():
99
+ raise GithubAuthenticationRequired()
100
+ _AUTH_TOKEN = _AUTHENTICATION_CACHE_LOCATION.read_text().strip()
101
+ return _AUTH_TOKEN
@@ -0,0 +1,32 @@
1
+ from typing import Optional
2
+
3
+ import hishel
4
+
5
+ from lazy_github.lib.config import Config
6
+ from lazy_github.lib.constants import JSON_CONTENT_ACCEPT_TYPE
7
+ from lazy_github.models.github import User
8
+
9
+
10
+ class GithubClient(hishel.AsyncCacheClient):
11
+ def __init__(self, config: Config, access_token: str) -> None:
12
+ storage = hishel.AsyncFileStorage(base_path=config.cache.cache_directory)
13
+ super().__init__(storage=storage, base_url=config.api.base_url)
14
+ self.config = config
15
+ self.access_token = access_token
16
+ self._user: User | None = None
17
+
18
+ def headers_with_auth_accept(
19
+ self, accept: str = JSON_CONTENT_ACCEPT_TYPE, cache_duration: Optional[int] = None
20
+ ) -> dict[str, str]:
21
+ """Helper function to build a request with specific headers"""
22
+ headers = {"Accept": accept, "Authorization": f"Bearer {self.access_token}"}
23
+ max_age = cache_duration or self.config.cache.default_ttl
24
+ headers["Cache-Control"] = f"max-age={max_age}"
25
+ return headers
26
+
27
+ async def user(self) -> User:
28
+ """Returns the authed user for this client"""
29
+ if self._user is None:
30
+ response = await self.get("/user", headers=self.headers_with_auth_accept())
31
+ self._user = User(**response.json())
32
+ return self._user
@@ -0,0 +1,40 @@
1
+ from functools import partial
2
+ from typing import Literal
3
+
4
+ from lazy_github.lib.github.client import GithubClient
5
+ from lazy_github.models.github import Issue, IssueComment, PartialPullRequest, Repository
6
+
7
+ IssueStateFilter = Literal["open"] | Literal["closed"] | Literal["all"]
8
+
9
+
10
+ async def _list(client: GithubClient, repo: Repository, state: IssueStateFilter) -> list[Issue]:
11
+ query_params = {"state": state}
12
+ headers = client.headers_with_auth_accept(cache_duration=client.config.cache.list_issues_ttl)
13
+ response = await client.get(f"/repos/{repo.owner.login}/{repo.name}/issues", headers=headers, params=query_params)
14
+ response.raise_for_status()
15
+ result: list[Issue] = []
16
+ for issue in response.json():
17
+ if "draft" in issue:
18
+ result.append(PartialPullRequest(**issue, repo=repo))
19
+ else:
20
+ result.append(Issue(**issue, repo=repo))
21
+ return result
22
+
23
+
24
+ list_open_issues = partial(_list, state="open")
25
+ list_closed_issues = partial(_list, state="closed")
26
+ list_all_issues = partial(_list, state="all")
27
+
28
+
29
+ async def get_comments(client: GithubClient, issue: Issue) -> list:
30
+ response = await client.get(issue.comments_url, headers=client.headers_with_auth_accept())
31
+ response.raise_for_status()
32
+ return response.json()
33
+
34
+
35
+ async def create_comment(client: GithubClient, repo: Repository, issue: Issue, comment_body: str) -> IssueComment:
36
+ url = f"/repos/{repo.owner.login}/{repo.name}/issues/{issue.number}/comments"
37
+ body = {"body": comment_body}
38
+ response = await client.post(url, json=body, headers=client.headers_with_auth_accept())
39
+ response.raise_for_status()
40
+ return IssueComment(**response.json())
@@ -0,0 +1,111 @@
1
+ from lazy_github.lib.github.client import GithubClient
2
+ from lazy_github.lib.constants import DIFF_CONTENT_ACCEPT_TYPE
3
+ from lazy_github.lib.github.issues import list_all_issues
4
+ from lazy_github.models.github import (
5
+ FullPullRequest,
6
+ Issue,
7
+ PartialPullRequest,
8
+ Repository,
9
+ Review,
10
+ ReviewComment,
11
+ )
12
+
13
+
14
+ async def list_for_repo(client: GithubClient, repo: Repository) -> list[PartialPullRequest]:
15
+ """Lists the pull requests associated with the specified repo"""
16
+ issues = await list_all_issues(client, repo)
17
+ return [i for i in issues if isinstance(i, PartialPullRequest)]
18
+
19
+
20
+ async def get_full_pull_request(client: GithubClient, partial_pr: PartialPullRequest) -> FullPullRequest:
21
+ """Converts a partial pull request into a full pull request"""
22
+ user = await client.user()
23
+ url = f"/repos/{user.login}/{partial_pr.repo.name}/pulls/{partial_pr.number}"
24
+ response = await client.get(url, headers=client.headers_with_auth_accept())
25
+ response.raise_for_status()
26
+ return FullPullRequest(**response.json(), repo=partial_pr.repo)
27
+
28
+
29
+ async def get_diff(client: GithubClient, pr: FullPullRequest) -> str:
30
+ """Fetches the raw diff for an individual pull request"""
31
+ headers = client.headers_with_auth_accept(DIFF_CONTENT_ACCEPT_TYPE)
32
+ response = await client.get(pr.diff_url, headers=headers, follow_redirects=True)
33
+ response.raise_for_status()
34
+ return response.text
35
+
36
+
37
+ async def get_review_comments(client: GithubClient, pr: FullPullRequest, review: Review) -> list[ReviewComment]:
38
+ user = await client.user()
39
+ url = f"/repos/{user.login}/{pr.repo.name}/pulls/{pr.number}/reviews/{review.id}/comments"
40
+ response = await client.get(url, headers=client.headers_with_auth_accept())
41
+ response.raise_for_status()
42
+ return [ReviewComment(**c) for c in response.json()]
43
+
44
+
45
+ async def get_reviews(client: GithubClient, pr: FullPullRequest, with_comments: bool = True) -> list[Review]:
46
+ user = await client.user()
47
+ url = url = f"/repos/{user.login}/{pr.repo.name}/pulls/{pr.number}/reviews"
48
+ response = await client.get(url, headers=client.headers_with_auth_accept())
49
+ response.raise_for_status()
50
+ reviews: list[Review] = []
51
+ for raw_review in response.json():
52
+ review = Review(**raw_review)
53
+ if with_comments:
54
+ review.comments = await get_review_comments(client, pr, review)
55
+ reviews.append(review)
56
+ return reviews
57
+
58
+
59
+ async def reply_to_review_comment(
60
+ client: GithubClient, repo: Repository, issue: Issue, comment: ReviewComment, comment_body: str
61
+ ) -> ReviewComment:
62
+ url = f"/repos/{repo.owner.login}/{repo.name}/pulls/{issue.number}/comments/{comment.id}/replies"
63
+ response = await client.post(url, headers=client.headers_with_auth_accept(), json={"body": comment_body})
64
+ response.raise_for_status()
65
+ return ReviewComment(**response.json())
66
+
67
+
68
+ class ReviewCommentNode:
69
+ def __init__(self, comment: ReviewComment) -> None:
70
+ self.children: list["ReviewCommentNode"] = []
71
+ self.comment = comment
72
+
73
+
74
+ def reconstruct_review_conversation_hierarchy(reviews: list[Review]) -> dict[int, ReviewCommentNode]:
75
+ """
76
+ Given a list of PR reviews, this rebuilds a the comment hierarchy as a tree of connected comment nodes. The return
77
+ value of this function is a mapping between the comment IDs and the associated ReviewCommentNode for the top level
78
+ comments ONLY. Any subsequent comments will be included as children in one of the review comment nodes.
79
+
80
+ An important disclaimer is that this function does NOT take into account the body associated with the review itself,
81
+ which is present in some reviews. When generating UI from this function, the body of review itself should be
82
+ included prior to printing the review comments themselves.
83
+
84
+ Given a variable `hierarchy` generated from a list `reviews` of PR reviews, the output of this can be properly
85
+ unpacked like so:
86
+ ```python
87
+ for review in reviews:
88
+ if review.body:
89
+ # Output the root review body
90
+ print(review.body)
91
+
92
+ # Output the review comments that are top level (i.e. their ids are in the hierarchy map)
93
+ for comment in review.comments:
94
+ if comment.id in hierarchy:
95
+ # Call
96
+ comment_review_node_handler(hierarchy[comment.id])
97
+ ```
98
+ """
99
+ comment_nodes_by_review_id: dict[int, ReviewCommentNode] = {}
100
+ # Create review nodes for all of the comments in each of the reviews
101
+ for review in reviews:
102
+ for comment in review.comments:
103
+ comment_nodes_by_review_id[comment.id] = ReviewCommentNode(comment)
104
+
105
+ # Build a tree that represents the conversational flow between individual comments in the threads
106
+ for review_node in comment_nodes_by_review_id.values():
107
+ in_reply_to_id = review_node.comment.in_reply_to_id
108
+ if in_reply_to_id is not None and in_reply_to_id in comment_nodes_by_review_id:
109
+ comment_nodes_by_review_id[in_reply_to_id].children.append(review_node)
110
+
111
+ return {r.comment.id: r for r in comment_nodes_by_review_id.values() if r.comment.in_reply_to_id is None}
@@ -0,0 +1,30 @@
1
+ from functools import partial
2
+ from typing import Literal
3
+
4
+ from lazy_github.lib.github.client import GithubClient
5
+ from lazy_github.models.github import Repository
6
+
7
+ RepoTypeFilter = Literal["all"] | Literal["owner"] | Literal["member"]
8
+ SortDirection = Literal["asc"] | Literal["desc"]
9
+ RepositorySortKey = Literal["created"] | Literal["updated"] | Literal["pushed"] | Literal["full_name"]
10
+
11
+
12
+ async def _list(
13
+ client: GithubClient,
14
+ repo_types: RepoTypeFilter,
15
+ sort: RepositorySortKey = "full_name",
16
+ direction: SortDirection = "asc",
17
+ page: int = 1,
18
+ per_page: int = 50,
19
+ ) -> list[Repository]:
20
+ """Retrieves Github repos matching the specified criteria"""
21
+ headers = client.headers_with_auth_accept(cache_duration=client.config.cache.list_repos_ttl)
22
+ query_params = {"type": repo_types, "direction": direction, "sort": sort, "page": page, "per_page": per_page}
23
+ response = await client.get("/user/repos", headers=headers, params=query_params)
24
+ response.raise_for_status()
25
+ return [Repository(**r) for r in response.json()]
26
+
27
+
28
+ list_all = partial(_list, repo_types="all")
29
+ list_owned = partial(_list, repo_types="owner")
30
+ list_member_of = partial(_list, repo_types="member")
@@ -0,0 +1,51 @@
1
+ from functools import cached_property
2
+
3
+ from textual.message import Message
4
+
5
+ from lazy_github.models.github import Issue, PartialPullRequest, Repository
6
+
7
+
8
+ class RepoSelected(Message):
9
+ """
10
+ A message indicating that a particular user repo has been selected.
11
+
12
+ This message is used to trigger follow-up contextual actions based on the selected repo, such as loading pull
13
+ requests, issues, actions, etc.
14
+ """
15
+
16
+ def __init__(self, repo: Repository) -> None:
17
+ self.repo = repo
18
+ super().__init__()
19
+
20
+
21
+ class PullRequestSelected(Message):
22
+ """
23
+ A message indicating that the user is looking for additional information on a particular pull request.
24
+ """
25
+
26
+ def __init__(self, pr: PartialPullRequest) -> None:
27
+ self.pr = pr
28
+ super().__init__()
29
+
30
+
31
+ class IssuesAndPullRequestsFetched(Message):
32
+ """
33
+ Since issues and pull requests are both represented on the Github API as issues, we want to pull issues once and
34
+ then send that message to both sections of the UI.
35
+ """
36
+
37
+ def __init__(self, issues_and_pull_requests: list[Issue]) -> None:
38
+ self.issues_and_pull_requests = issues_and_pull_requests
39
+ super().__init__()
40
+
41
+ @cached_property
42
+ def pull_requests(self) -> list[PartialPullRequest]:
43
+ return [pr for pr in self.issues_and_pull_requests if isinstance(pr, PartialPullRequest)]
44
+
45
+ @cached_property
46
+ def issues(self) -> list[Issue]:
47
+ return [
48
+ issue
49
+ for issue in self.issues_and_pull_requests
50
+ if isinstance(issue, Issue) and not isinstance(issue, PartialPullRequest)
51
+ ]
@@ -0,0 +1,16 @@
1
+ def pluralize(count: int, singular: str, plural: str):
2
+ """
3
+ Helper function for correctly pluralizing strings in the UI. This is simple but gets messy when written many times
4
+ across the UI code.
5
+ """
6
+ return f"{count} {singular}" if count == 1 else f"{count} {plural}"
7
+
8
+
9
+ def bold(s: str) -> str:
10
+ """Wraps the given text in Rich bold tags"""
11
+ return f"[bold]{s}[/bold]"
12
+
13
+
14
+ def link(link_text: str, url: str) -> str:
15
+ """Formats a link in Rich-style markup"""
16
+ return f"[link={url}]{link_text}[/link]"