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.
Files changed (47) hide show
  1. lazy_github-0.0.0/LICENSE +21 -0
  2. lazy_github-0.0.0/PKG-INFO +73 -0
  3. lazy_github-0.0.0/README.md +31 -0
  4. lazy_github-0.0.0/lazy_github/__main__.py +4 -0
  5. lazy_github-0.0.0/lazy_github/cli.py +59 -0
  6. lazy_github-0.0.0/lazy_github/lib/bindings.py +72 -0
  7. lazy_github-0.0.0/lazy_github/lib/config.py +129 -0
  8. lazy_github-0.0.0/lazy_github/lib/constants.py +41 -0
  9. lazy_github-0.0.0/lazy_github/lib/context.py +46 -0
  10. lazy_github-0.0.0/lazy_github/lib/git_cli.py +30 -0
  11. lazy_github-0.0.0/lazy_github/lib/github/auth.py +100 -0
  12. lazy_github-0.0.0/lazy_github/lib/github/branches.py +28 -0
  13. lazy_github-0.0.0/lazy_github/lib/github/client.py +32 -0
  14. lazy_github-0.0.0/lazy_github/lib/github/issues.py +67 -0
  15. lazy_github-0.0.0/lazy_github/lib/github/pull_requests.py +128 -0
  16. lazy_github-0.0.0/lazy_github/lib/github/repositories.py +55 -0
  17. lazy_github-0.0.0/lazy_github/lib/github/workflows.py +42 -0
  18. lazy_github-0.0.0/lazy_github/lib/github_cli.py +37 -0
  19. lazy_github-0.0.0/lazy_github/lib/logging.py +29 -0
  20. lazy_github-0.0.0/lazy_github/lib/messages.py +85 -0
  21. lazy_github-0.0.0/lazy_github/lib/utils.py +26 -0
  22. lazy_github-0.0.0/lazy_github/models/github.py +173 -0
  23. lazy_github-0.0.0/lazy_github/ui/app.py +62 -0
  24. lazy_github-0.0.0/lazy_github/ui/screens/auth.py +90 -0
  25. lazy_github-0.0.0/lazy_github/ui/screens/edit_issue.py +93 -0
  26. lazy_github-0.0.0/lazy_github/ui/screens/new_comment.py +146 -0
  27. lazy_github-0.0.0/lazy_github/ui/screens/new_issue.py +94 -0
  28. lazy_github-0.0.0/lazy_github/ui/screens/new_pull_request.py +215 -0
  29. lazy_github-0.0.0/lazy_github/ui/screens/primary.py +378 -0
  30. lazy_github-0.0.0/lazy_github/ui/screens/settings.py +320 -0
  31. lazy_github-0.0.0/lazy_github/ui/widgets/command_log.py +40 -0
  32. lazy_github-0.0.0/lazy_github/ui/widgets/common.py +192 -0
  33. lazy_github-0.0.0/lazy_github/ui/widgets/conversations.py +120 -0
  34. lazy_github-0.0.0/lazy_github/ui/widgets/info.py +48 -0
  35. lazy_github-0.0.0/lazy_github/ui/widgets/issues.py +189 -0
  36. lazy_github-0.0.0/lazy_github/ui/widgets/pull_requests.py +237 -0
  37. lazy_github-0.0.0/lazy_github/ui/widgets/repositories.py +125 -0
  38. lazy_github-0.0.0/lazy_github/ui/widgets/workflows.py +137 -0
  39. lazy_github-0.0.0/lazy_github/version.py +1 -0
  40. lazy_github-0.0.0/lazy_github.egg-info/PKG-INFO +73 -0
  41. lazy_github-0.0.0/lazy_github.egg-info/SOURCES.txt +45 -0
  42. lazy_github-0.0.0/lazy_github.egg-info/dependency_links.txt +1 -0
  43. lazy_github-0.0.0/lazy_github.egg-info/entry_points.txt +2 -0
  44. lazy_github-0.0.0/lazy_github.egg-info/requires.txt +6 -0
  45. lazy_github-0.0.0/lazy_github.egg-info/top_level.txt +1 -0
  46. lazy_github-0.0.0/pyproject.toml +51 -0
  47. 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
+ ![PyPI - Version](https://img.shields.io/pypi/v/lazy-github) ![PyPI - Downloads](https://img.shields.io/pypi/dw/lazy-github)
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
+ ![Example screenshot](https://raw.githubusercontent.com/gizmo385/lazy-github/main/images/lazy-github-conversation-ui.svg)
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
+ ![Settings screenshot](https://raw.githubusercontent.com/gizmo385/lazy-github/main/images/lazy-github-settings-ui.png)
@@ -0,0 +1,31 @@
1
+ ![PyPI - Version](https://img.shields.io/pypi/v/lazy-github) ![PyPI - Downloads](https://img.shields.io/pypi/dw/lazy-github)
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
+ ![Example screenshot](https://raw.githubusercontent.com/gizmo385/lazy-github/main/images/lazy-github-conversation-ui.svg)
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
+ ![Settings screenshot](https://raw.githubusercontent.com/gizmo385/lazy-github/main/images/lazy-github-settings-ui.png)
@@ -0,0 +1,4 @@
1
+ from lazy_github.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -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