lazy-github 0.0.0__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.0.0/LICENSE +21 -0
- lazy_github-0.0.0/PKG-INFO +73 -0
- lazy_github-0.0.0/README.md +31 -0
- lazy_github-0.0.0/lazy_github/__main__.py +4 -0
- lazy_github-0.0.0/lazy_github/cli.py +59 -0
- lazy_github-0.0.0/lazy_github/lib/bindings.py +72 -0
- lazy_github-0.0.0/lazy_github/lib/config.py +129 -0
- lazy_github-0.0.0/lazy_github/lib/constants.py +41 -0
- lazy_github-0.0.0/lazy_github/lib/context.py +46 -0
- lazy_github-0.0.0/lazy_github/lib/git_cli.py +30 -0
- lazy_github-0.0.0/lazy_github/lib/github/auth.py +100 -0
- lazy_github-0.0.0/lazy_github/lib/github/branches.py +28 -0
- lazy_github-0.0.0/lazy_github/lib/github/client.py +32 -0
- lazy_github-0.0.0/lazy_github/lib/github/issues.py +67 -0
- lazy_github-0.0.0/lazy_github/lib/github/pull_requests.py +128 -0
- lazy_github-0.0.0/lazy_github/lib/github/repositories.py +55 -0
- lazy_github-0.0.0/lazy_github/lib/github/workflows.py +42 -0
- lazy_github-0.0.0/lazy_github/lib/github_cli.py +37 -0
- lazy_github-0.0.0/lazy_github/lib/logging.py +29 -0
- lazy_github-0.0.0/lazy_github/lib/messages.py +85 -0
- lazy_github-0.0.0/lazy_github/lib/utils.py +26 -0
- lazy_github-0.0.0/lazy_github/models/github.py +173 -0
- lazy_github-0.0.0/lazy_github/ui/app.py +62 -0
- lazy_github-0.0.0/lazy_github/ui/screens/auth.py +90 -0
- lazy_github-0.0.0/lazy_github/ui/screens/edit_issue.py +93 -0
- lazy_github-0.0.0/lazy_github/ui/screens/new_comment.py +146 -0
- lazy_github-0.0.0/lazy_github/ui/screens/new_issue.py +94 -0
- lazy_github-0.0.0/lazy_github/ui/screens/new_pull_request.py +215 -0
- lazy_github-0.0.0/lazy_github/ui/screens/primary.py +378 -0
- lazy_github-0.0.0/lazy_github/ui/screens/settings.py +320 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/command_log.py +40 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/common.py +192 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/conversations.py +120 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/info.py +48 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/issues.py +189 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/pull_requests.py +237 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/repositories.py +125 -0
- lazy_github-0.0.0/lazy_github/ui/widgets/workflows.py +137 -0
- lazy_github-0.0.0/lazy_github/version.py +1 -0
- lazy_github-0.0.0/lazy_github.egg-info/PKG-INFO +73 -0
- lazy_github-0.0.0/lazy_github.egg-info/SOURCES.txt +45 -0
- lazy_github-0.0.0/lazy_github.egg-info/dependency_links.txt +1 -0
- lazy_github-0.0.0/lazy_github.egg-info/entry_points.txt +2 -0
- lazy_github-0.0.0/lazy_github.egg-info/requires.txt +6 -0
- lazy_github-0.0.0/lazy_github.egg-info/top_level.txt +1 -0
- lazy_github-0.0.0/pyproject.toml +51 -0
- lazy_github-0.0.0/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,73 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: lazy-github
|
|
3
|
+
Version: 0.0.0
|
|
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: httpx
|
|
37
|
+
Requires-Dist: hishel
|
|
38
|
+
Requires-Dist: pydantic
|
|
39
|
+
Requires-Dist: textual
|
|
40
|
+
Requires-Dist: textual-dev
|
|
41
|
+
Requires-Dist: click>=8.1.7
|
|
42
|
+
|
|
43
|
+
 
|
|
44
|
+
|
|
45
|
+
LazyGithub is a terminal UI client for interacting with [GitHub](https://github.com). It draws heavy inspiration from the
|
|
46
|
+
[lazygit](https://github.com/jesseduffield/lazygit) project and uses [Textual](https://textual.textualize.io/) to drive the terminal UI interactions.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
## How to Use It
|
|
51
|
+
|
|
52
|
+
You can run the [most recently built version](https://pypi.org/project/lazy-github/) by installing it from PyPI. If you have [uv installed](https://github.com/astral-sh/uv), you can do that easily with `uvx lazy-github`.
|
|
53
|
+
|
|
54
|
+
When you first start LazyGithub, you will be prompted with a device login code and a link to GitHub
|
|
55
|
+
where you will be able to authenticate the app against your account. This allows the app to act on
|
|
56
|
+
your behalf and is necessary for LazyGithub to function.
|
|
57
|
+
|
|
58
|
+
Currently, it supports the following:
|
|
59
|
+
|
|
60
|
+
- Listing the repositories associated with your account
|
|
61
|
+
- Listing the issues, pull requests, and actions on your repositories
|
|
62
|
+
- Listing the details, diff, and reviews on any of those pull requests
|
|
63
|
+
- Detailed issue and pull request views, including conversation participation
|
|
64
|
+
|
|
65
|
+
If you wish to run it from a local clone of the repository, you can do so by running the `./start.sh` located in the root of the repo.
|
|
66
|
+
|
|
67
|
+
## Customization
|
|
68
|
+
|
|
69
|
+
LazyGithub supports a number of customization options, all of which are stored in `$HOME/.config/lazy-github/config.json`.
|
|
70
|
+
These can be edited manually via changing the config or by opening the settings management UI within LazyGithub. That UI
|
|
71
|
+
can be accessed via the command pallete (`CMD+p`) and then searching for settings.
|
|
72
|
+
|
|
73
|
+

|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
 
|
|
2
|
+
|
|
3
|
+
LazyGithub is a terminal UI client for interacting with [GitHub](https://github.com). It draws heavy inspiration from the
|
|
4
|
+
[lazygit](https://github.com/jesseduffield/lazygit) project and uses [Textual](https://textual.textualize.io/) to drive the terminal UI interactions.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
## How to Use It
|
|
9
|
+
|
|
10
|
+
You can run the [most recently built version](https://pypi.org/project/lazy-github/) by installing it from PyPI. If you have [uv installed](https://github.com/astral-sh/uv), you can do that easily with `uvx lazy-github`.
|
|
11
|
+
|
|
12
|
+
When you first start LazyGithub, you will be prompted with a device login code and a link to GitHub
|
|
13
|
+
where you will be able to authenticate the app against your account. This allows the app to act on
|
|
14
|
+
your behalf and is necessary for LazyGithub to function.
|
|
15
|
+
|
|
16
|
+
Currently, it supports the following:
|
|
17
|
+
|
|
18
|
+
- Listing the repositories associated with your account
|
|
19
|
+
- Listing the issues, pull requests, and actions on your repositories
|
|
20
|
+
- Listing the details, diff, and reviews on any of those pull requests
|
|
21
|
+
- Detailed issue and pull request views, including conversation participation
|
|
22
|
+
|
|
23
|
+
If you wish to run it from a local clone of the repository, you can do so by running the `./start.sh` located in the root of the repo.
|
|
24
|
+
|
|
25
|
+
## Customization
|
|
26
|
+
|
|
27
|
+
LazyGithub supports a number of customization options, all of which are stored in `$HOME/.config/lazy-github/config.json`.
|
|
28
|
+
These can be edited manually via changing the config or by opening the settings management UI within LazyGithub. That UI
|
|
29
|
+
can be accessed via the command pallete (`CMD+p`) and then searching for settings.
|
|
30
|
+
|
|
31
|
+

|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import rich
|
|
5
|
+
|
|
6
|
+
from lazy_github.lib.config import _CONFIG_FILE_LOCATION, Config
|
|
7
|
+
from lazy_github.lib.context import LazyGithubContext
|
|
8
|
+
from lazy_github.ui.app import app
|
|
9
|
+
|
|
10
|
+
_CLI_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group(invoke_without_command=True, context_settings=_CLI_CONTEXT_SETTINGS)
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def cli(ctx: click.Context) -> None:
|
|
16
|
+
"""A Terminal UI for interacting with Github"""
|
|
17
|
+
if ctx.invoked_subcommand is None:
|
|
18
|
+
run()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cli.command
|
|
22
|
+
def run():
|
|
23
|
+
"""Run LazyGithub"""
|
|
24
|
+
app.run()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.command
|
|
28
|
+
def dump_config():
|
|
29
|
+
"""Dump the current configuration, as it would be loaded by LazyGithub"""
|
|
30
|
+
print(f"Config file location: {_CONFIG_FILE_LOCATION} (exists => {_CONFIG_FILE_LOCATION.exists()})")
|
|
31
|
+
rich.print_json(Config.load_config().model_dump_json())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cli.command
|
|
35
|
+
def clear_auth():
|
|
36
|
+
"""Clears out any existing authentication config for LazyGithub, forcing the user to relogin"""
|
|
37
|
+
from lazy_github.lib.github.auth import _AUTHENTICATION_CACHE_LOCATION
|
|
38
|
+
|
|
39
|
+
_AUTHENTICATION_CACHE_LOCATION.unlink(missing_ok=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cli.command
|
|
43
|
+
def clear_config():
|
|
44
|
+
"""Reset the user's settings"""
|
|
45
|
+
_CONFIG_FILE_LOCATION.unlink(missing_ok=True)
|
|
46
|
+
print("Your settings have been cleared")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command
|
|
50
|
+
@click.option("--no-confirm", is_flag=True, default=False, help="Don't ask for confirmation")
|
|
51
|
+
def clear_cache(no_confirm: bool):
|
|
52
|
+
"""Reset the lazy-github cache"""
|
|
53
|
+
cache_directory = LazyGithubContext.config.cache.cache_directory
|
|
54
|
+
if no_confirm or click.confirm(f"Confirm deletion of everything in {cache_directory}"):
|
|
55
|
+
if cache_directory.exists():
|
|
56
|
+
shutil.rmtree(cache_directory)
|
|
57
|
+
print("Cache cleared")
|
|
58
|
+
else:
|
|
59
|
+
print("Canceling cache deletion")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from textual.binding import Binding
|
|
2
|
+
|
|
3
|
+
from lazy_github.lib.utils import classproperty
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LazyGithubBindings:
|
|
7
|
+
# Global App Bindings
|
|
8
|
+
QUIT_APP = Binding("q", "quit", "Quit", id="app.quit")
|
|
9
|
+
OPEN_COMMAND_PALLETE = Binding("ctrl+p", "command_palette", "Open command pallete", id="app.command_palette")
|
|
10
|
+
MAXIMIZE_WIDGET = Binding("ctrl+m", "maximize", "Maximize", id="app.maximize_widget")
|
|
11
|
+
|
|
12
|
+
# Triggering creation flows
|
|
13
|
+
OPEN_ISSUE = Binding("I", "open_issue", "Open new issue", id="issue.new")
|
|
14
|
+
EDIT_ISSUE = Binding("E", "edit_issue", "Edit issue", id="issue.edit")
|
|
15
|
+
OPEN_PULL_REQUEST = Binding("P", "open_pull_request", "Open new pull request", id="pull_request.new")
|
|
16
|
+
NEW_COMMENT = Binding("n", "new_comment", "New comment", id="conversation.comment.new")
|
|
17
|
+
REPLY_TO_REVIEW = Binding("r", "reply_to_review", "Reply to review", id="conversation.review.reply")
|
|
18
|
+
REPLY_TO_COMMENT = Binding("r", "reply_to_individual_comment", "Reply to comment", id="conversation.comment.reply")
|
|
19
|
+
|
|
20
|
+
# Repository actions
|
|
21
|
+
TOGGLE_FAVORITE_REPO = Binding("ctrl+f", "toggle_favorite_repo", "Toggle favorite", id="repositories.favorite")
|
|
22
|
+
|
|
23
|
+
# Common widget bindings
|
|
24
|
+
SELECT_ENTRY = Binding("enter,space", "select_cursor", "Select table entry", id="common.table.select", show=False)
|
|
25
|
+
SEARCH_TABLE = Binding("/", "focus_search", "Search", id="common.table.search")
|
|
26
|
+
TABLE_DOWN = Binding("j", "cursor_down", "Table cursor down", show=False, id="common.table.cursor_down")
|
|
27
|
+
TABLE_PAGE_DOWN = Binding("J", "page_down", "Table page down", show=False, id="common.table.page_down")
|
|
28
|
+
TABLE_CURSOR_UP = Binding("k", "cursor_up", "Table cursor up", show=False, id="common.table.cursor_up")
|
|
29
|
+
TABLE_PAGE_UP = Binding("K", "page_up", "Table page up", show=False, id="common.table.page_up")
|
|
30
|
+
TABLE_SCROLL_RIGHT = Binding("l", "scroll_right", "Table scroll right", show=False, id="common.table.scroll_right")
|
|
31
|
+
TABLE_PAGE_RIGHT = Binding("L", "page_right", "Table page right", show=False, id="common.table.page_right")
|
|
32
|
+
TABLE_SCROLL_LEFT = Binding("h", "scroll_left", "Table scroll left", show=False, id="common.table.scroll_left")
|
|
33
|
+
TABLE_PAGE_LEFT = Binding("H", "page_left", "Table page left", show=False, id="common.table.page_left")
|
|
34
|
+
TABLE_SCROLL_TOP = Binding("g", "scroll_top", "Table scroll to top", show=False, id="common.table.scroll_top")
|
|
35
|
+
TABLE_SCROLL_BOTTOM = Binding(
|
|
36
|
+
"G", "scroll_bottom", "Table scroll to bottom", show=False, id="common.table.scroll_bottom"
|
|
37
|
+
)
|
|
38
|
+
TABLE_PAGE_LEFT = Binding("^", "page_left", "Table page left", show=False, id="common.table.page_left")
|
|
39
|
+
TABLE_PAGE_RIGHT = Binding("$", "page_right", "Table page right", show=False, id="common.table.page_right")
|
|
40
|
+
|
|
41
|
+
# Dialog bindings
|
|
42
|
+
SUBMIT_DIALOG = Binding("shift+enter", "submit", "Submit", id="modal.submit")
|
|
43
|
+
CANCEL_DIALOG = Binding("q, ESC", "cancel", "Cancel", id="modal.cancel")
|
|
44
|
+
SEARCH_DIALOG = Binding("/", "search", "Search", id="modal.search")
|
|
45
|
+
|
|
46
|
+
# Focusing different UI elements
|
|
47
|
+
FOCUS_REPOSITORY_TABLE = Binding(
|
|
48
|
+
"1", "focus_section('#repos_table')", "Focus repos table", show=False, id="main.repos.focus"
|
|
49
|
+
)
|
|
50
|
+
FOCUS_PULL_REQUEST_TABLE = Binding(
|
|
51
|
+
"2", "focus_section('#pull_requests_table')", "Focus PRs table", show=False, id="main.pull_requests.focus"
|
|
52
|
+
)
|
|
53
|
+
FOCUS_ISSUE_TABLE = Binding(
|
|
54
|
+
"3", "focus_section('#issues_table')", "Focus issues table", show=False, id="main.issues.focus"
|
|
55
|
+
)
|
|
56
|
+
FOCUS_WORKFLOW_TABS = Binding(
|
|
57
|
+
"4", "focus_workflow_tabs", "Focus workflows table", show=False, id="main.workflows.focus"
|
|
58
|
+
)
|
|
59
|
+
FOCUS_DETAIL_TABS = Binding("5", "focus_tabs", "Focus details tabs", show=False, id="main.details.focus")
|
|
60
|
+
FOCUS_COMMAND_LOG = Binding(
|
|
61
|
+
"6", "focus_section('LazyGithubCommandLog')", "Focus command log", show=False, id="main.command_log.focus"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classproperty
|
|
65
|
+
def all(cls) -> list[Binding]:
|
|
66
|
+
"""Returns all bindings which can be rebound"""
|
|
67
|
+
return [v for v in cls.__dict__.values() if isinstance(v, Binding) if v.id]
|
|
68
|
+
|
|
69
|
+
@classproperty
|
|
70
|
+
def all_by_id(cls) -> dict[str, Binding]:
|
|
71
|
+
"""Returns a dictionary of all bindings which can be rebound, with the key being their ID"""
|
|
72
|
+
return {v.id: v for v in cls.__dict__.values() if isinstance(v, Binding) and v.id}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Generator, Literal, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, field_serializer, field_validator
|
|
8
|
+
from textual.theme import BUILTIN_THEMES, Theme
|
|
9
|
+
|
|
10
|
+
from lazy_github.lib.constants import CONFIG_FOLDER, IssueOwnerFilter, IssueStateFilter
|
|
11
|
+
|
|
12
|
+
_CONFIG_FILE_LOCATION = CONFIG_FOLDER / "config.json"
|
|
13
|
+
|
|
14
|
+
ISSUE_STATE_FILTER = Literal["all"] | Literal["open"] | Literal["closed"]
|
|
15
|
+
ISSUE_OWNER_FILTER = Literal["mine"] | Literal["all"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AppearanceSettings(BaseModel):
|
|
19
|
+
"""Settings focused on altering the appearance of LazyGithub, including hiding or showing different sections."""
|
|
20
|
+
|
|
21
|
+
theme: Theme = BUILTIN_THEMES["textual-dark"]
|
|
22
|
+
# Settings to configure which UI elements to display by default
|
|
23
|
+
show_command_log: bool = True
|
|
24
|
+
show_workflows: bool = True
|
|
25
|
+
show_issues: bool = True
|
|
26
|
+
show_pull_requests: bool = True
|
|
27
|
+
|
|
28
|
+
@field_serializer("theme")
|
|
29
|
+
@classmethod
|
|
30
|
+
def serialize_theme(cls, theme: Theme | str) -> str:
|
|
31
|
+
return theme.name if isinstance(theme, Theme) else theme
|
|
32
|
+
|
|
33
|
+
@field_validator("theme", mode="before")
|
|
34
|
+
@classmethod
|
|
35
|
+
def validate_theme(cls, theme_name: Any) -> Theme:
|
|
36
|
+
return BUILTIN_THEMES.get(theme_name, BUILTIN_THEMES["textual-dark"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotificationSettings(BaseModel):
|
|
40
|
+
"""Controls the settings for the optional notification feature, which relies on the standard GitHub CLI."""
|
|
41
|
+
|
|
42
|
+
enabled: bool = False
|
|
43
|
+
show_all_notifications: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BindingsSettings(BaseModel):
|
|
47
|
+
"""Custom keybinding overrides for LazyGithub. When rebinding, pressing ESCAPE will reset to the default binding."""
|
|
48
|
+
|
|
49
|
+
overrides: dict[str, str] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RepositorySettings(BaseModel):
|
|
53
|
+
"""Repository-specific settings"""
|
|
54
|
+
|
|
55
|
+
favorites: list[str] = []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PullRequestSettings(BaseModel):
|
|
59
|
+
"""Changes how pull requests are retrieved from the Github API"""
|
|
60
|
+
|
|
61
|
+
state_filter: IssueStateFilter = IssueStateFilter.ALL
|
|
62
|
+
owner_filter: IssueOwnerFilter = IssueOwnerFilter.ALL
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class IssueSettings(BaseModel):
|
|
66
|
+
"""Changes how issues are retrieved from the Github API"""
|
|
67
|
+
|
|
68
|
+
state_filter: IssueStateFilter = IssueStateFilter.ALL
|
|
69
|
+
owner_filter: IssueOwnerFilter = IssueOwnerFilter.ALL
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CacheSettings(BaseModel):
|
|
73
|
+
"""Settings that control how long data will be cached from the GitHub API"""
|
|
74
|
+
|
|
75
|
+
cache_directory: Path = CONFIG_FOLDER / ".cache"
|
|
76
|
+
default_ttl: int = int(timedelta(minutes=10).total_seconds())
|
|
77
|
+
list_repos_ttl: int = int(timedelta(days=1).total_seconds())
|
|
78
|
+
list_issues_ttl: int = int(timedelta(hours=1).total_seconds())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CoreConfig(BaseModel):
|
|
82
|
+
logfile_path: Path = CONFIG_FOLDER / "lazy_github.log"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ApiConfig(BaseModel):
|
|
86
|
+
"""Controlling how the GitHub API is accessed in LazyGithub"""
|
|
87
|
+
|
|
88
|
+
base_url: str = "https://api.github.com"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_CONFIG_INSTANCE: Optional["Config"] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Config(BaseModel):
|
|
95
|
+
appearance: AppearanceSettings = AppearanceSettings()
|
|
96
|
+
notifications: NotificationSettings = NotificationSettings()
|
|
97
|
+
bindings: BindingsSettings = BindingsSettings()
|
|
98
|
+
repositories: RepositorySettings = RepositorySettings()
|
|
99
|
+
pull_requests: PullRequestSettings = PullRequestSettings()
|
|
100
|
+
issues: IssueSettings = IssueSettings()
|
|
101
|
+
cache: CacheSettings = CacheSettings()
|
|
102
|
+
core: CoreConfig = CoreConfig()
|
|
103
|
+
api: ApiConfig = ApiConfig()
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def load_config(cls) -> "Config":
|
|
107
|
+
global _CONFIG_INSTANCE
|
|
108
|
+
if _CONFIG_INSTANCE is None:
|
|
109
|
+
if _CONFIG_FILE_LOCATION.exists():
|
|
110
|
+
_CONFIG_INSTANCE = cls(**json.loads(_CONFIG_FILE_LOCATION.read_text()))
|
|
111
|
+
else:
|
|
112
|
+
_CONFIG_INSTANCE = cls()
|
|
113
|
+
return _CONFIG_INSTANCE
|
|
114
|
+
|
|
115
|
+
def save(self) -> None:
|
|
116
|
+
CONFIG_FOLDER.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
_CONFIG_FILE_LOCATION.write_text(self.model_dump_json(indent=4))
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
@contextmanager
|
|
121
|
+
def to_edit(cls) -> Generator["Config", None, None]:
|
|
122
|
+
current_config = cls.load_config()
|
|
123
|
+
yield current_config
|
|
124
|
+
current_config.save()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
print(f"Config file location: {_CONFIG_FILE_LOCATION} (exists => {_CONFIG_FILE_LOCATION.exists()})")
|
|
129
|
+
print(Config.load_config().model_dump_json(indent=4))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Content types
|
|
5
|
+
DIFF_CONTENT_ACCEPT_TYPE = "application/vnd.github.diff"
|
|
6
|
+
JSON_CONTENT_ACCEPT_TYPE = "application/vnd.github+json"
|
|
7
|
+
|
|
8
|
+
# App access information
|
|
9
|
+
LAZY_GITHUB_CLIENT_ID = "Iv23limdG8Bl3Cu5FOcT"
|
|
10
|
+
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
11
|
+
|
|
12
|
+
# Symbols used in various UI tables
|
|
13
|
+
IS_FAVORITED = "[green]★[/green]"
|
|
14
|
+
IS_NOT_FAVORITED = "☆"
|
|
15
|
+
IS_PRIVATE = "✔"
|
|
16
|
+
IS_PUBLIC = "✘"
|
|
17
|
+
|
|
18
|
+
NOTIFICATION_REFRESH_INTERVAL = 60
|
|
19
|
+
|
|
20
|
+
CONFIG_FOLDER = Path.home() / ".config/lazy-github"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def favorite_string(favorite: bool) -> str:
|
|
24
|
+
"""Helper function to return the right string to indicate if something is favorited"""
|
|
25
|
+
return IS_FAVORITED if favorite else IS_NOT_FAVORITED
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def private_string(private: bool) -> str:
|
|
29
|
+
"""Helper function to return the right string to indicate if something is private"""
|
|
30
|
+
return IS_PRIVATE if private else IS_PUBLIC
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IssueStateFilter(StrEnum):
|
|
34
|
+
ALL = "all"
|
|
35
|
+
OPEN = "open"
|
|
36
|
+
CLOSED = "closed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IssueOwnerFilter(StrEnum):
|
|
40
|
+
MINE = "mine"
|
|
41
|
+
ALL = "all"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from lazy_github.lib.config import Config
|
|
4
|
+
from lazy_github.lib.constants import JSON_CONTENT_ACCEPT_TYPE
|
|
5
|
+
from lazy_github.lib.git_cli import current_local_repo_full_name
|
|
6
|
+
from lazy_github.lib.github.auth import token
|
|
7
|
+
from lazy_github.lib.github.client import GithubClient
|
|
8
|
+
from lazy_github.lib.utils import classproperty
|
|
9
|
+
from lazy_github.models.github import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LazyGithubContext:
|
|
13
|
+
"""Globally accessible wrapper class that centralizes access to the configuration and the Github API client"""
|
|
14
|
+
|
|
15
|
+
# Attributes exposed via properties
|
|
16
|
+
_config: Config | None = None
|
|
17
|
+
_client: GithubClient | None = None
|
|
18
|
+
_current_directory_repo: str | None = None
|
|
19
|
+
|
|
20
|
+
# Directly assigned attributes
|
|
21
|
+
current_repo: Repository | None = None
|
|
22
|
+
|
|
23
|
+
@classproperty
|
|
24
|
+
def config(cls) -> Config:
|
|
25
|
+
if cls._config is None:
|
|
26
|
+
cls._config = Config.load_config()
|
|
27
|
+
return cls._config
|
|
28
|
+
|
|
29
|
+
@classproperty
|
|
30
|
+
def client(cls) -> GithubClient:
|
|
31
|
+
# Ideally this is would just be a none check but that doesn't properly type check for some reason
|
|
32
|
+
if not isinstance(cls._client, GithubClient):
|
|
33
|
+
cls._client = GithubClient(cls.config, token())
|
|
34
|
+
return cls._client
|
|
35
|
+
|
|
36
|
+
@classproperty
|
|
37
|
+
def current_directory_repo(cls) -> str | None:
|
|
38
|
+
"""The owner/name of the repo associated with the current working directory (if one exists)"""
|
|
39
|
+
if not cls._current_directory_repo:
|
|
40
|
+
cls._current_directory_repo = current_local_repo_full_name()
|
|
41
|
+
return cls._current_directory_repo
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def github_headers(accept: str = JSON_CONTENT_ACCEPT_TYPE, cache_duration: Optional[int] = None) -> dict[str, str]:
|
|
45
|
+
"""Helper function to build headers for Github API requests"""
|
|
46
|
+
return LazyGithubContext.client.github_headers(accept, cache_duration)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from subprocess import DEVNULL, SubprocessError, check_output
|
|
3
|
+
|
|
4
|
+
# Regex designed to match git@github.com:gizmo385/lazy-github.git:
|
|
5
|
+
# ".+:" Match everything to the first colon
|
|
6
|
+
# "([^\/]+)" Match everything until the forward slash, which should be owner
|
|
7
|
+
# "\/" Match the forward slash
|
|
8
|
+
# "([^.]+)" Match everything until the period, which should be the repo name
|
|
9
|
+
# ".git" Match the .git suffix
|
|
10
|
+
_GIT_REMOTE_REGEX = re.compile(r".+:([^\/]+)\/([^.]+).git")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def current_local_repo_full_name(remote: str = "origin") -> str | None:
|
|
14
|
+
"""Returns the owner/name associated with the remote of the git repo in the current working directory."""
|
|
15
|
+
try:
|
|
16
|
+
output = check_output(["git", "remote", "get-url", remote], stderr=DEVNULL).decode().strip()
|
|
17
|
+
except SubprocessError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
if matches := re.match(_GIT_REMOTE_REGEX, output):
|
|
21
|
+
owner, name = matches.groups()
|
|
22
|
+
return f"{owner}/{name}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def current_local_branch_name() -> str | None:
|
|
26
|
+
"""Returns the name of the current branch for the git repo in the current working directory."""
|
|
27
|
+
try:
|
|
28
|
+
return check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=DEVNULL).decode().strip()
|
|
29
|
+
except SubprocessError:
|
|
30
|
+
return None
|
|
@@ -0,0 +1,100 @@
|
|
|
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, DEVICE_CODE_GRANT_TYPE, LAZY_GITHUB_CLIENT_ID
|
|
8
|
+
|
|
9
|
+
# Auth and client globals
|
|
10
|
+
_AUTHENTICATION_CACHE_LOCATION = CONFIG_FOLDER / "auth.text"
|
|
11
|
+
_AUTH_TOKEN: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DeviceCodeResponse:
|
|
16
|
+
device_code: str
|
|
17
|
+
verification_uri: str
|
|
18
|
+
user_code: str
|
|
19
|
+
polling_interval: int
|
|
20
|
+
expires_at: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AccessTokenResponse:
|
|
25
|
+
token: Optional[str]
|
|
26
|
+
error: Optional[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GithubAuthenticationRequired(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_device_code() -> DeviceCodeResponse:
|
|
34
|
+
"""
|
|
35
|
+
Authenticates this device with the Github API. This will require the user to go enter the provided device code on
|
|
36
|
+
the Github UI to authenticate the LazyGithub app.
|
|
37
|
+
"""
|
|
38
|
+
async with httpx.AsyncClient() as client:
|
|
39
|
+
response = await client.post(
|
|
40
|
+
"https://github.com/login/device/code",
|
|
41
|
+
data={"client_id": LAZY_GITHUB_CLIENT_ID},
|
|
42
|
+
headers={"Accept": "application/json"},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
body = response.json()
|
|
47
|
+
expires_at = time.time() + body["expires_in"]
|
|
48
|
+
return DeviceCodeResponse(
|
|
49
|
+
body["device_code"],
|
|
50
|
+
body["verification_uri"],
|
|
51
|
+
body["user_code"],
|
|
52
|
+
body["interval"],
|
|
53
|
+
expires_at,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def get_access_token(device_code: DeviceCodeResponse) -> AccessTokenResponse:
|
|
58
|
+
"""Given a device code, retrieves the oauth access token that can be used to send requests to the GIthub API"""
|
|
59
|
+
async with httpx.AsyncClient() as client:
|
|
60
|
+
# TODO: This should specify an accept
|
|
61
|
+
access_token_res = await client.post(
|
|
62
|
+
"https://github.com/login/oauth/access_token",
|
|
63
|
+
data={
|
|
64
|
+
"client_id": LAZY_GITHUB_CLIENT_ID,
|
|
65
|
+
"grant_type": DEVICE_CODE_GRANT_TYPE,
|
|
66
|
+
"device_code": device_code.device_code,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
access_token_res.raise_for_status()
|
|
70
|
+
pairs = access_token_res.text.split("&")
|
|
71
|
+
access_token_data = dict(pair.split("=") for pair in pairs)
|
|
72
|
+
return AccessTokenResponse(
|
|
73
|
+
access_token_data.get("access_token"),
|
|
74
|
+
access_token_data.get("error"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_access_token(access_token: AccessTokenResponse) -> None:
|
|
79
|
+
"""Writes the returned access token to the config location"""
|
|
80
|
+
if not access_token.token:
|
|
81
|
+
raise ValueError("Invalid access token response! Cannot save")
|
|
82
|
+
|
|
83
|
+
# Create the parent directories for our cache if it's present
|
|
84
|
+
_AUTHENTICATION_CACHE_LOCATION.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
_AUTHENTICATION_CACHE_LOCATION.write_text(access_token.token)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def token() -> str:
|
|
89
|
+
"""
|
|
90
|
+
Helper function which loads the token from the file on disk. If the file does not exist, it raises a
|
|
91
|
+
GithubAuthenticationRequired exception that the caller should handle by triggering the auth flow
|
|
92
|
+
"""
|
|
93
|
+
global _AUTH_TOKEN
|
|
94
|
+
if _AUTH_TOKEN is not None:
|
|
95
|
+
return _AUTH_TOKEN
|
|
96
|
+
|
|
97
|
+
if not _AUTHENTICATION_CACHE_LOCATION.exists():
|
|
98
|
+
raise GithubAuthenticationRequired()
|
|
99
|
+
_AUTH_TOKEN = _AUTHENTICATION_CACHE_LOCATION.read_text().strip()
|
|
100
|
+
return _AUTH_TOKEN
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from lazy_github.lib.constants import DIFF_CONTENT_ACCEPT_TYPE
|
|
2
|
+
from lazy_github.lib.context import LazyGithubContext, github_headers
|
|
3
|
+
from lazy_github.models.github import Branch, Repository
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def list_branches(repo: Repository, per_page: int = 30, page: int = 1) -> list[Branch]:
|
|
7
|
+
"""List branches on the specified repo"""
|
|
8
|
+
query_params = {"page": page, "per_page": per_page}
|
|
9
|
+
response = await LazyGithubContext.client.get(
|
|
10
|
+
f"/repos/{repo.owner.login}/{repo.name}/branches",
|
|
11
|
+
headers=github_headers(),
|
|
12
|
+
params=query_params,
|
|
13
|
+
)
|
|
14
|
+
response.raise_for_status()
|
|
15
|
+
return [Branch(**branch) for branch in response.json()]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_branch(repo: Repository, branch_name: str) -> Branch | None:
|
|
19
|
+
url = f"/repos/{repo.owner.login}/{repo.name}/branches/{branch_name}"
|
|
20
|
+
response = await LazyGithubContext.client.get(url, headers=github_headers())
|
|
21
|
+
return Branch(**response.json())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def compare_branches(repo: Repository, base_branch: Branch, head_branch: Branch) -> str:
|
|
25
|
+
url = f"/repos/{repo.owner.login}/{repo.name}/compare/{base_branch.name}..{head_branch.name}"
|
|
26
|
+
response = await LazyGithubContext.client.get(url, headers=github_headers(accept=DIFF_CONTENT_ACCEPT_TYPE))
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
return response.text
|