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.
- lazy_github-0.1/LICENSE +21 -0
- lazy_github-0.1/PKG-INFO +67 -0
- lazy_github-0.1/README.md +25 -0
- lazy_github-0.1/lazy_github/__main__.py +9 -0
- lazy_github-0.1/lazy_github/lib/config.py +66 -0
- lazy_github-0.1/lazy_github/lib/constants.py +27 -0
- lazy_github-0.1/lazy_github/lib/github/auth.py +101 -0
- lazy_github-0.1/lazy_github/lib/github/client.py +32 -0
- lazy_github-0.1/lazy_github/lib/github/issues.py +40 -0
- lazy_github-0.1/lazy_github/lib/github/pull_requests.py +111 -0
- lazy_github-0.1/lazy_github/lib/github/repositories.py +30 -0
- lazy_github-0.1/lazy_github/lib/messages.py +51 -0
- lazy_github-0.1/lazy_github/lib/string_utils.py +16 -0
- lazy_github-0.1/lazy_github/models/github.py +128 -0
- lazy_github-0.1/lazy_github/ui/app.py +28 -0
- lazy_github-0.1/lazy_github/ui/screens/auth.py +92 -0
- lazy_github-0.1/lazy_github/ui/screens/new_comment.py +143 -0
- lazy_github-0.1/lazy_github/ui/screens/primary.py +189 -0
- lazy_github-0.1/lazy_github/ui/widgets/actions.py +14 -0
- lazy_github-0.1/lazy_github/ui/widgets/command_log.py +36 -0
- lazy_github-0.1/lazy_github/ui/widgets/common.py +26 -0
- lazy_github-0.1/lazy_github/ui/widgets/conversations.py +116 -0
- lazy_github-0.1/lazy_github/ui/widgets/issues.py +44 -0
- lazy_github-0.1/lazy_github/ui/widgets/pull_requests.py +228 -0
- lazy_github-0.1/lazy_github/ui/widgets/repositories.py +105 -0
- lazy_github-0.1/lazy_github.egg-info/PKG-INFO +67 -0
- lazy_github-0.1/lazy_github.egg-info/SOURCES.txt +31 -0
- lazy_github-0.1/lazy_github.egg-info/dependency_links.txt +1 -0
- lazy_github-0.1/lazy_github.egg-info/entry_points.txt +2 -0
- lazy_github-0.1/lazy_github.egg-info/requires.txt +6 -0
- lazy_github-0.1/lazy_github.egg-info/top_level.txt +1 -0
- lazy_github-0.1/pyproject.toml +45 -0
- lazy_github-0.1/setup.cfg +4 -0
lazy_github-0.1/LICENSE
ADDED
|
@@ -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.
|
lazy_github-0.1/PKG-INFO
ADDED
|
@@ -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,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]"
|