lazy-github 0.3.0__tar.gz → 0.3.2__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 (60) hide show
  1. lazy_github-0.3.2/.github/workflows/publish.yaml +61 -0
  2. {lazy_github-0.3.0 → lazy_github-0.3.2}/PKG-INFO +1 -1
  3. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/bindings.py +4 -3
  4. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/config.py +8 -0
  5. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/constants.py +1 -1
  6. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/context.py +23 -1
  7. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/backends/cli.py +10 -1
  8. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/backends/hishel.py +9 -0
  9. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/backends/protocol.py +7 -0
  10. lazy_github-0.3.2/lazy_github/lib/github/checks.py +13 -0
  11. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/client.py +3 -0
  12. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/pull_requests.py +18 -5
  13. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/logging.py +0 -6
  14. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/models/github.py +28 -0
  15. lazy_github-0.3.2/lazy_github/ui/screens/lookup_pull_request.py +88 -0
  16. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/lookup_repository.py +3 -3
  17. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/new_issue.py +4 -0
  18. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/new_pull_request.py +10 -0
  19. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/primary.py +1 -1
  20. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/pull_requests.py +105 -5
  21. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/repositories.py +0 -1
  22. lazy_github-0.3.2/lazy_github/version.py +1 -0
  23. lazy_github-0.3.0/.github/workflows/publish.yaml +0 -44
  24. lazy_github-0.3.0/lazy_github/version.py +0 -1
  25. {lazy_github-0.3.0 → lazy_github-0.3.2}/.devcontainer/devcontainer.json +0 -0
  26. {lazy_github-0.3.0 → lazy_github-0.3.2}/.github/workflows/code-checks.yaml +0 -0
  27. {lazy_github-0.3.0 → lazy_github-0.3.2}/.gitignore +0 -0
  28. {lazy_github-0.3.0 → lazy_github-0.3.2}/.pre-commit-config.yaml +0 -0
  29. {lazy_github-0.3.0 → lazy_github-0.3.2}/LICENSE +0 -0
  30. {lazy_github-0.3.0 → lazy_github-0.3.2}/README.md +0 -0
  31. {lazy_github-0.3.0 → lazy_github-0.3.2}/images/lazy-github-conversation-ui.svg +0 -0
  32. {lazy_github-0.3.0 → lazy_github-0.3.2}/images/lazy-github-settings-ui.png +0 -0
  33. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/__main__.py +0 -0
  34. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/cli.py +0 -0
  35. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/git_cli.py +0 -0
  36. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/auth.py +0 -0
  37. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/branches.py +0 -0
  38. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/issues.py +0 -0
  39. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/notifications.py +0 -0
  40. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/repositories.py +0 -0
  41. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/github/workflows.py +0 -0
  42. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/messages.py +0 -0
  43. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/lib/utils.py +0 -0
  44. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/app.py +0 -0
  45. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/auth.py +0 -0
  46. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/edit_issue.py +0 -0
  47. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/new_comment.py +0 -0
  48. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/notifications.py +0 -0
  49. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/settings.py +0 -0
  50. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/screens/trigger_workflow.py +0 -0
  51. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/command_log.py +0 -0
  52. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/common.py +0 -0
  53. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/conversations.py +0 -0
  54. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/info.py +0 -0
  55. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/issues.py +0 -0
  56. {lazy_github-0.3.0 → lazy_github-0.3.2}/lazy_github/ui/widgets/workflows.py +0 -0
  57. {lazy_github-0.3.0 → lazy_github-0.3.2}/lint.sh +0 -0
  58. {lazy_github-0.3.0 → lazy_github-0.3.2}/pyproject.toml +0 -0
  59. {lazy_github-0.3.0 → lazy_github-0.3.2}/start.sh +0 -0
  60. {lazy_github-0.3.0 → lazy_github-0.3.2}/uv.lock +0 -0
@@ -0,0 +1,61 @@
1
+ name: Publish the package to pypi
2
+
3
+ on: workflow_dispatch
4
+
5
+ jobs:
6
+ pypi-publish:
7
+ name: Upload release to PyPI
8
+ runs-on: ubuntu-latest
9
+ environment:
10
+ name: pypi
11
+ url: https://pypi.org/p/lazy-github
12
+ permissions:
13
+ id-token: write
14
+ outputs:
15
+ new-version: ${{ steps.calculate-version.outputs.version }}
16
+ steps:
17
+ # Perform a bunch of setup
18
+ - uses: actions/checkout@v3
19
+ - uses: actions/setup-python@v4
20
+ - uses: yezz123/setup-uv@v4
21
+
22
+ - name: Calculate version
23
+ id: calculate-version
24
+ run: |
25
+ new_version=$(uvx hatch version)
26
+ echo "version=${new_version}" >> $GITHUB_OUTPUT
27
+
28
+ # Build the distribution
29
+ - name: Build lazy-github distribution
30
+ run: uvx hatch build
31
+
32
+ - name: Publish package distributions to PyPI
33
+ uses: pypa/gh-action-pypi-publish@release/v1
34
+
35
+ create-tag:
36
+ name: Create tag
37
+ needs: pypi-publish
38
+ permissions: write-all
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - uses: actions/github-script@v7
42
+ with:
43
+ script: |
44
+ github.rest.git.createRef({
45
+ owner: context.repo.owner,
46
+ repo: context.repo.repo,
47
+ ref: 'refs/tags/v${{ needs.pypi-publish.outputs.new-version }}',
48
+ sha: context.sha
49
+ })
50
+
51
+ notify-discord:
52
+ name: Notify when this workflow completes (regardless of success or failure)
53
+ needs: pypi-publish
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: nobrayner/discord-webhook@v1
57
+ with:
58
+ title: "Version ${{ needs.pypi-publish.outputs.new-version }} published to PyPi"
59
+ description: "Check out the new version [here](https://pypi.org/project/lazy-github/${{ needs.pypi-publish.outputs.new-version }}/)"
60
+ github-token: ${{ secrets.github_token }}
61
+ discord-webhook: ${{ secrets.DISCORD_WEBHOOK }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazy-github
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A terminal UI for interacting with Github
5
5
  Author-email: "Chris (Gizmo)" <gizmo385@users.noreply.github.com>
6
6
  Maintainer-email: "Chris (Gizmo)" <gizmo385@users.noreply.github.com>
@@ -8,18 +8,19 @@ class LazyGithubBindings:
8
8
  # Global App Bindings
9
9
  QUIT_APP = Binding("q", "quit", "Quit", id="app.quit")
10
10
  OPEN_COMMAND_PALLETE = Binding("ctrl+p", "command_palette", "Commands", id="app.command_palette")
11
- MAXIMIZE_WIDGET = Binding("ctrl+m", "maximize", "Maximize", id="app.maximize_widget")
11
+ MAXIMIZE_WIDGET = Binding("M", "maximize", "Maximize", id="app.maximize_widget")
12
12
 
13
13
  # Triggering creation flows
14
14
  OPEN_ISSUE = Binding("I", "open_issue", "New Issue", id="issue.new")
15
15
  EDIT_ISSUE = Binding("E", "edit_issue", "Edit Issue", id="issue.edit")
16
- OPEN_PULL_REQUEST = Binding("P", "open_pull_request", "New PR", id="pull_request.new")
17
16
  NEW_COMMENT = Binding("n", "new_comment", "New Comment", id="conversation.comment.new")
18
17
  REPLY_TO_REVIEW = Binding("r", "reply_to_review", "Reply to Review", id="conversation.review.reply")
19
18
  REPLY_TO_COMMENT = Binding("r", "reply_to_individual_comment", "Reply to Comment", id="conversation.comment.reply")
20
19
 
21
20
  # Pull request actions
22
- MERGE_PULL_REQUEST = Binding("M", "merge_pull_request", "Merge PR", id="pull_request.merge")
21
+ OPEN_PULL_REQUEST = Binding("P", "open_pull_request", "New PR", id="pull_request.new")
22
+ MERGE_PULL_REQUEST = Binding("ctrl+m", "merge_pull_request", "Merge PR", id="pull_request.merge")
23
+ LOOKUP_PULL_REQUEST = Binding("O", "lookup_pull_request", "Lookup Pull Request", id="pull_request.lookup")
23
24
 
24
25
  # Repository actions
25
26
  TOGGLE_FAVORITE_REPO = Binding("ctrl+f", "toggle_favorite_repo", "Toggle Favorite", id="repositories.favorite")
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  from contextlib import contextmanager
3
3
  from datetime import timedelta
4
+ from enum import StrEnum
4
5
  from pathlib import Path
5
6
  from typing import Any, Generator, Literal, Optional
6
7
 
@@ -71,11 +72,18 @@ class RepositorySettings(BaseModel):
71
72
  favorites: list[str] = []
72
73
 
73
74
 
75
+ class MergeMethod(StrEnum):
76
+ MERGE = "merge"
77
+ SQUASH = "squash"
78
+ REBASE = "rebase"
79
+
80
+
74
81
  class PullRequestSettings(BaseModel):
75
82
  """Changes how pull requests are retrieved from the Github API"""
76
83
 
77
84
  state_filter: IssueStateFilter = IssueStateFilter.ALL
78
85
  owner_filter: IssueOwnerFilter = IssueOwnerFilter.ALL
86
+ preferred_merge_method: MergeMethod = MergeMethod.SQUASH
79
87
 
80
88
 
81
89
  class IssueSettings(BaseModel):
@@ -12,7 +12,7 @@ DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
12
12
  # Symbols used in various UI tables
13
13
  IS_FAVORITED = "[green]★[/green]"
14
14
  IS_NOT_FAVORITED = "☆"
15
- CHECKMARK = ""
15
+ CHECKMARK = ""
16
16
  X_MARK = "✘"
17
17
  BULLET_POINT = "•"
18
18
 
@@ -1,10 +1,12 @@
1
+ import logging
1
2
  from typing import Optional
2
3
 
3
4
  from lazy_github.lib.config import Config
4
5
  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.git_cli import current_local_branch_name, current_local_repo_full_name
6
7
  from lazy_github.lib.github.backends.protocol import BackendType
7
8
  from lazy_github.lib.github.client import GithubClient
9
+ from lazy_github.lib.logging import LazyGithubLogFormatter, lg
8
10
  from lazy_github.lib.utils import classproperty
9
11
  from lazy_github.models.github import Repository
10
12
 
@@ -16,14 +18,27 @@ class LazyGithubContext:
16
18
  _config: Config | None = None
17
19
  _client: GithubClient | None = None
18
20
  _current_directory_repo: str | None = None
21
+ _current_directory_branch: str | None = None
19
22
 
20
23
  # Directly assigned attributes
21
24
  current_repo: Repository | None = None
22
25
 
26
+ @classmethod
27
+ def _setup_logging_handler(cls, config: Config) -> None:
28
+ """Setup the file logger for LazyGithub"""
29
+ try:
30
+ config.core.logfile_path.parent.mkdir(parents=True, exist_ok=True)
31
+ lg_file_handler = logging.FileHandler(filename=config.core.logfile_path)
32
+ lg_file_handler.setFormatter(LazyGithubLogFormatter())
33
+ lg.addHandler(lg_file_handler)
34
+ except Exception:
35
+ lg.exception("Failed to setup file logger for LazyGithub")
36
+
23
37
  @classproperty
24
38
  def config(cls) -> Config:
25
39
  if cls._config is None:
26
40
  cls._config = Config.load_config()
41
+ cls._setup_logging_handler(cls._config)
27
42
  return cls._config
28
43
 
29
44
  @classproperty
@@ -52,6 +67,13 @@ class LazyGithubContext:
52
67
  cls._current_directory_repo = current_local_repo_full_name()
53
68
  return cls._current_directory_repo
54
69
 
70
+ @classproperty
71
+ def current_directory_branch(cls) -> str | None:
72
+ """The owner/name of the repo associated with the current working directory (if one exists)"""
73
+ if not cls._current_directory_branch:
74
+ cls._current_directory_branch = current_local_branch_name()
75
+ return cls._current_directory_branch
76
+
55
77
 
56
78
  def github_headers(accept: str = JSON_CONTENT_ACCEPT_TYPE, cache_duration: Optional[int] = None) -> dict[str, str]:
57
79
  """Helper function to build headers for Github API requests"""
@@ -9,6 +9,7 @@ from typing import Any
9
9
  from lazy_github.lib.config import Config
10
10
  from lazy_github.lib.constants import CONFIG_FOLDER, JSON_CONTENT_ACCEPT_TYPE
11
11
  from lazy_github.lib.github.backends.protocol import GithubApiBackend, GithubApiRequestFailed, GithubApiResponse
12
+ from lazy_github.lib.logging import lg
12
13
  from lazy_github.models.github import User
13
14
 
14
15
  _HEADER_RE = re.compile(r"^([a-zA-Z-]+)\:(.+)$")
@@ -66,7 +67,6 @@ def _clear_temporary_bodies() -> None:
66
67
 
67
68
  async def run_gh_cli_command(command: list[str]) -> CliApiResponse:
68
69
  """Simple wrapper around running a Github CLI command"""
69
- from lazy_github.lib.logging import lg
70
70
 
71
71
  proc = await asyncio.create_subprocess_exec(
72
72
  "gh", *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
@@ -158,6 +158,15 @@ class GithubCliBackend(GithubApiBackend):
158
158
  command = _build_command(url, headers=headers, body=json, method="PATCH")
159
159
  return await run_gh_cli_command(command)
160
160
 
161
+ async def put(
162
+ self,
163
+ url: str,
164
+ headers: dict[str, str] | None = None,
165
+ json: dict[str, str] | None = None,
166
+ ) -> Any:
167
+ command = _build_command(url, headers=headers, body=json, method="PUT")
168
+ return await run_gh_cli_command(command)
169
+
161
170
  async def get_user(self) -> User:
162
171
  response = await self.get("/user")
163
172
  return User(**response.json())
@@ -69,6 +69,15 @@ class HishelGithubApiBackend(GithubApiBackend):
69
69
  response = await self.api_client.patch(url, headers=headers, json=json)
70
70
  return HishelApiResponse(response)
71
71
 
72
+ async def put(
73
+ self,
74
+ url: str,
75
+ headers: dict[str, str] | None = None,
76
+ json: dict[str, str] | None = None,
77
+ ) -> HishelApiResponse:
78
+ response = await self.api_client.put(url, headers=headers, json=json)
79
+ return HishelApiResponse(response)
80
+
72
81
  def github_headers(
73
82
  self, accept: str = JSON_CONTENT_ACCEPT_TYPE, cache_duration: int | None = None
74
83
  ) -> dict[str, str]:
@@ -48,6 +48,13 @@ class GithubApiBackend(Protocol):
48
48
  json: dict[str, str] | None = None,
49
49
  ) -> GithubApiResponse: ...
50
50
 
51
+ async def put(
52
+ self,
53
+ url: str,
54
+ headers: dict[str, str] | None = None,
55
+ json: dict[str, str] | None = None,
56
+ ) -> GithubApiResponse: ...
57
+
51
58
  async def get_user(self) -> User: ...
52
59
 
53
60
  def github_headers(
@@ -0,0 +1,13 @@
1
+ from lazy_github.lib.context import LazyGithubContext, github_headers
2
+ from lazy_github.models.github import CombinedCheckStatus, Repository
3
+
4
+
5
+ async def combined_check_status_for_ref(
6
+ repo: Repository, ref: str, per_page: int = 100, page: int = 1
7
+ ) -> CombinedCheckStatus:
8
+ query_params = {"page": page, "per_page": per_page}
9
+ response = await LazyGithubContext.client.get(
10
+ f"/repos/{repo.owner.login}/{repo.name}/commits/{ref}/status", headers=github_headers(), params=query_params
11
+ )
12
+ response.raise_for_status()
13
+ return CombinedCheckStatus(**response.json())
@@ -45,5 +45,8 @@ class GithubClient(GithubApiBackend):
45
45
  async def patch(self, url: str, headers: dict[str, str] | None = None, json: dict[str, str] | None = None) -> Any:
46
46
  return await self.backend.patch(url, headers, json)
47
47
 
48
+ async def put(self, url: str, headers: dict[str, str] | None = None, json: dict[str, str] | None = None) -> Any:
49
+ return await self.backend.put(url, headers, json)
50
+
48
51
  async def get_user(self) -> User:
49
52
  return await self.backend.get_user()
@@ -1,3 +1,4 @@
1
+ from lazy_github.lib.config import MergeMethod
1
2
  from lazy_github.lib.constants import DIFF_CONTENT_ACCEPT_TYPE
2
3
  from lazy_github.lib.context import LazyGithubContext, github_headers
3
4
  from lazy_github.lib.github.backends.cli import run_gh_cli_command
@@ -7,6 +8,7 @@ from lazy_github.models.github import (
7
8
  FullPullRequest,
8
9
  Issue,
9
10
  PartialPullRequest,
11
+ PullRequestMergeResult,
10
12
  Repository,
11
13
  Review,
12
14
  ReviewComment,
@@ -24,13 +26,13 @@ async def list_for_repo(repo: Repository) -> list[PartialPullRequest]:
24
26
  async def create_pull_request(
25
27
  repo: Repository, title: str, body: str, base_ref: str, head_ref: str, draft: bool = False
26
28
  ) -> FullPullRequest:
27
- user = await LazyGithubContext.client.user()
28
29
  url = f"/repos/{repo.owner.login}/{repo.name}/pulls"
29
30
  request_body = {
30
31
  "title": title,
31
32
  "draft": draft,
32
33
  "base": base_ref,
33
- "head": f"{user.login}:{head_ref}",
34
+ # TODO: This prevents it from working with forks, but means it'll work for same-repo PRs. Issue
35
+ "head": f"{repo.owner.login}:{head_ref}",
34
36
  }
35
37
  if body:
36
38
  request_body["body"] = body
@@ -39,12 +41,12 @@ async def create_pull_request(
39
41
  return FullPullRequest(**response.json(), repo=repo)
40
42
 
41
43
 
42
- async def get_full_pull_request(partial_pr: PartialPullRequest) -> FullPullRequest:
44
+ async def get_full_pull_request(repo: Repository, pr_number: int) -> FullPullRequest:
43
45
  """Converts a partial pull request into a full pull request"""
44
- url = f"/repos/{partial_pr.repo.owner.login}/{partial_pr.repo.name}/pulls/{partial_pr.number}"
46
+ url = f"/repos/{repo.owner.login}/{repo.name}/pulls/{pr_number}"
45
47
  response = await LazyGithubContext.client.get(url, headers=github_headers())
46
48
  response.raise_for_status()
47
- return FullPullRequest(**response.json(), repo=partial_pr.repo)
49
+ return FullPullRequest(**response.json(), repo=repo)
48
50
 
49
51
 
50
52
  async def get_diff(pr: FullPullRequest) -> str:
@@ -62,6 +64,17 @@ async def get_diff(pr: FullPullRequest) -> str:
62
64
  return response.text
63
65
 
64
66
 
67
+ async def merge_pull_request(pr: FullPullRequest, merge_method: MergeMethod) -> PullRequestMergeResult:
68
+ """
69
+ Attempts to merge the PR via the Github API. The head sha of the PR must match for the merge to be successful.
70
+ """
71
+ url = f"/repos/{pr.repo.owner.login}/{pr.repo.name}/pulls/{pr.number}/merge"
72
+ body = {"merge_method": merge_method, "sha": pr.head.sha}
73
+ response = await LazyGithubContext.client.put(url, headers=github_headers(), json=body)
74
+ response.raise_for_status()
75
+ return PullRequestMergeResult(**response.json())
76
+
77
+
65
78
  async def get_review_comments(pr: FullPullRequest, review: Review) -> list[ReviewComment]:
66
79
  url = f"/repos/{pr.repo.owner.login}/{pr.repo.name}/pulls/{pr.number}/reviews/{review.id}/comments"
67
80
  response = await LazyGithubContext.client.get(url, headers=github_headers())
@@ -1,7 +1,5 @@
1
1
  import logging
2
2
 
3
- from lazy_github.lib.context import LazyGithubContext
4
-
5
3
 
6
4
  # A universal logging format that we can use
7
5
  class LazyGithubLogFormatter(logging.Formatter):
@@ -16,11 +14,7 @@ class LazyGithubLogFormatter(logging.Formatter):
16
14
  return super().format(record)
17
15
 
18
16
 
19
- LazyGithubContext.config.core.logfile_path.parent.mkdir(parents=True, exist_ok=True)
20
17
  lg = logging.Logger("lazy_github", level=logging.DEBUG)
21
- _lg_file_handler = logging.FileHandler(filename=LazyGithubContext.config.core.logfile_path)
22
- _lg_file_handler.setFormatter(LazyGithubLogFormatter())
23
- lg.addHandler(_lg_file_handler)
24
18
 
25
19
 
26
20
  # Override the logging level for a bunch of noisy library loggers
@@ -61,6 +61,7 @@ class Issue(BaseModel):
61
61
  class Ref(BaseModel):
62
62
  user: User
63
63
  ref: str
64
+ sha: str
64
65
 
65
66
 
66
67
  class PartialPullRequest(Issue):
@@ -84,6 +85,12 @@ class FullPullRequest(PartialPullRequest):
84
85
  diff_url: str
85
86
 
86
87
 
88
+ class PullRequestMergeResult(BaseModel):
89
+ sha: str
90
+ merged: bool
91
+ message: str
92
+
93
+
87
94
  class AuthorAssociation(StrEnum):
88
95
  COLLABORATOR = "COLLABORATOR"
89
96
  CONTRIBUTOR = "CONTRIBUTOR"
@@ -189,3 +196,24 @@ class Notification(BaseModel):
189
196
  unread: bool
190
197
  updated_at: datetime
191
198
  last_read_at: datetime | None
199
+
200
+
201
+ class CheckStatusState(StrEnum):
202
+ SUCCESS = "success"
203
+ PENDING = "pending"
204
+ ERROR = "error"
205
+ FAILURE = "failure"
206
+
207
+
208
+ class CheckStatus(BaseModel):
209
+ description: str
210
+ context: str
211
+ state: CheckStatusState
212
+ target_url: str | None
213
+ updated_at: datetime | None
214
+ created_at: datetime | None
215
+
216
+
217
+ class CombinedCheckStatus(BaseModel):
218
+ state: CheckStatusState
219
+ statuses: list[CheckStatus]
@@ -0,0 +1,88 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Container, Horizontal
4
+ from textual.screen import ModalScreen
5
+ from textual.widgets import Button, Input, Label, Markdown, Rule
6
+
7
+ from lazy_github.lib.bindings import LazyGithubBindings
8
+ from lazy_github.lib.context import LazyGithubContext
9
+ from lazy_github.lib.github.backends.protocol import GithubApiRequestFailed
10
+ from lazy_github.lib.github.pull_requests import get_full_pull_request
11
+ from lazy_github.models.github import FullPullRequest
12
+ from lazy_github.ui.widgets.common import LazyGithubFooter
13
+
14
+
15
+ class LookupPullRequestButtons(Horizontal):
16
+ DEFAULT_CSS = """
17
+ LookupPullRequestButtons {
18
+ align: center middle;
19
+ height: auto;
20
+ width: 100%;
21
+ }
22
+ Button {
23
+ margin: 1;
24
+ }
25
+ """
26
+
27
+ def compose(self) -> ComposeResult:
28
+ yield Button("Open", id="lookup", variant="success")
29
+ yield Button("Cancel", id="cancel", variant="error")
30
+
31
+
32
+ class LookupPullRequestContainer(Container):
33
+ DEFAULT_CSS = """
34
+ LookupPullRequestContainer {
35
+ align: center middle;
36
+ }
37
+ """
38
+
39
+ def compose(self) -> ComposeResult:
40
+ yield Markdown("# Search for a pull request by number:")
41
+ yield Label("[bold]Pull Request Number:[/bold]")
42
+ yield Input(
43
+ id="pull_request_number",
44
+ placeholder="Pull request number",
45
+ type="number",
46
+ )
47
+ yield Rule()
48
+ yield LookupPullRequestButtons()
49
+
50
+
51
+ class LookupPullRequestModal(ModalScreen[FullPullRequest | None]):
52
+ DEFAULT_CSS = """
53
+ LookupPullRequestModal {
54
+ align: center middle;
55
+ content-align: center middle;
56
+ }
57
+
58
+ LookupPullRequestContainer {
59
+ width: 60;
60
+ max-height: 25;
61
+ border: thick $background 80%;
62
+ background: $surface-lighten-3;
63
+ }
64
+ """
65
+
66
+ BINDINGS = [LazyGithubBindings.SUBMIT_DIALOG, LazyGithubBindings.CLOSE_DIALOG]
67
+
68
+ def compose(self) -> ComposeResult:
69
+ yield LookupPullRequestContainer()
70
+ yield LazyGithubFooter()
71
+
72
+ @on(Button.Pressed, "#lookup")
73
+ async def action_submit(self) -> None:
74
+ assert LazyGithubContext.current_repo is not None, "Current repo is missing!"
75
+
76
+ try:
77
+ pr_number = int(self.query_one("#pull_request_number", Input).value)
78
+ pull_request = await get_full_pull_request(LazyGithubContext.current_repo, pr_number)
79
+ except ValueError:
80
+ self.notify("Must enter a valid pull request number!", title="Invalid PR Number", severity="error")
81
+ except GithubApiRequestFailed:
82
+ self.notify("Could not find pull request!", title="Unknown PR", severity="error")
83
+ else:
84
+ self.dismiss(pull_request)
85
+
86
+ @on(Button.Pressed, "#cancel")
87
+ async def action_close(self) -> None:
88
+ self.dismiss(None)
@@ -54,11 +54,11 @@ class LookupRepositoryContainer(Container):
54
54
  class LookupRepositoryModal(ModalScreen[Repository | None]):
55
55
  DEFAULT_CSS = """
56
56
  LookupRepositoryModal {
57
- height: 80%;
57
+ align: center middle;
58
+ content-align: center middle;
58
59
  }
59
60
 
60
61
  LookupRepositoryContainer {
61
- dock: top;
62
62
  width: 60;
63
63
  max-height: 25;
64
64
  border: thick $background 80%;
@@ -78,7 +78,7 @@ class LookupRepositoryModal(ModalScreen[Repository | None]):
78
78
  # If we haven't tracked this repo already, we will do so
79
79
  config.repositories.additional_repos_to_track.append(repo_name)
80
80
 
81
- @on(Button.Pressed, "#submit")
81
+ @on(Button.Pressed, "#lookup")
82
82
  async def action_submit(self) -> None:
83
83
  repo_input = self.query_one("#repo_to_lookup", Input)
84
84
  continue_tracking_input = self.query_one("#continue_tracking", Switch)
@@ -22,6 +22,10 @@ class NewIssueContainer(Container):
22
22
  height: 80%;
23
23
  }
24
24
 
25
+ #new_issue_title {
26
+ height: auto;
27
+ }
28
+
25
29
  #new_issue_body {
26
30
  height: 15;
27
31
  }
@@ -64,8 +64,18 @@ class BranchSelection(Horizontal):
64
64
  def base_ref(self) -> str:
65
65
  return self._base_ref_input.value
66
66
 
67
+ @work
68
+ async def set_default_branch_value(self) -> None:
69
+ if (
70
+ LazyGithubContext.current_directory_repo
71
+ and LazyGithubContext.current_repo
72
+ and LazyGithubContext.current_directory_repo == LazyGithubContext.current_repo.full_name
73
+ ):
74
+ self.query_one("#head_ref", Input).value = LazyGithubContext.current_directory_branch
75
+
67
76
  async def on_mount(self) -> None:
68
77
  self.fetch_branches()
78
+ self.set_default_branch_value()
69
79
 
70
80
  @on(BranchesLoaded)
71
81
  def handle_loaded_branches(self, message: BranchesLoaded) -> None:
@@ -270,7 +270,7 @@ class MainViewPane(Container):
270
270
  return self.query_one("#selection_details", SelectionDetailsContainer)
271
271
 
272
272
  async def on_pull_request_selected(self, message: PullRequestSelected) -> None:
273
- full_pr = await get_full_pull_request(message.pr)
273
+ full_pr = await get_full_pull_request(message.pr.repo, message.pr.number)
274
274
  tabbed_content = self.query_one("#selection_detail_tabs", TabbedContent)
275
275
  await tabbed_content.clear_panes()
276
276
  await tabbed_content.add_pane(PrOverviewTabPane(full_pr))
@@ -3,20 +3,30 @@ from textual import on, work
3
3
  from textual.app import ComposeResult
4
4
  from textual.containers import ScrollableContainer, VerticalScroll
5
5
  from textual.coordinate import Coordinate
6
- from textual.widgets import DataTable, Label, Markdown, RichLog, Rule, TabPane
6
+ from textual.widgets import Collapsible, DataTable, Label, ListItem, ListView, Markdown, RichLog, Rule, TabPane
7
7
 
8
8
  from lazy_github.lib.bindings import LazyGithubBindings
9
+ from lazy_github.lib.constants import CHECKMARK, X_MARK
9
10
  from lazy_github.lib.context import LazyGithubContext
11
+ from lazy_github.lib.github.backends.protocol import GithubApiRequestFailed
12
+ from lazy_github.lib.github.checks import combined_check_status_for_ref
10
13
  from lazy_github.lib.github.issues import get_comments, list_issues
11
14
  from lazy_github.lib.github.pull_requests import (
12
15
  get_diff,
13
16
  get_reviews,
17
+ merge_pull_request,
14
18
  reconstruct_review_conversation_hierarchy,
15
19
  )
16
20
  from lazy_github.lib.logging import lg
17
21
  from lazy_github.lib.messages import IssuesAndPullRequestsFetched, PullRequestSelected
18
22
  from lazy_github.lib.utils import bold, link, pluralize
19
- from lazy_github.models.github import FullPullRequest, PartialPullRequest
23
+ from lazy_github.models.github import (
24
+ CheckStatus,
25
+ CheckStatusState,
26
+ FullPullRequest,
27
+ PartialPullRequest,
28
+ )
29
+ from lazy_github.ui.screens.lookup_pull_request import LookupPullRequestModal
20
30
  from lazy_github.ui.screens.new_comment import NewCommentModal
21
31
  from lazy_github.ui.widgets.common import LazilyLoadedDataTable, LazyGithubContainer
22
32
  from lazy_github.ui.widgets.conversations import IssueCommentContainer, ReviewContainer
@@ -31,6 +41,8 @@ class PullRequestsContainer(LazyGithubContainer):
31
41
  This container includes the primary datatable for viewing pull requests on the UI.
32
42
  """
33
43
 
44
+ BINDINGS = [LazyGithubBindings.LOOKUP_PULL_REQUEST]
45
+
34
46
  def __init__(self, *args, **kwargs) -> None:
35
47
  super().__init__(*args, **kwargs)
36
48
  self.pull_requests: dict[int, PartialPullRequest] = {}
@@ -50,6 +62,16 @@ class PullRequestsContainer(LazyGithubContainer):
50
62
  reverse_sort=True,
51
63
  )
52
64
 
65
+ @work
66
+ async def action_lookup_pull_request(self) -> None:
67
+ if pr := await self.app.push_screen_wait(LookupPullRequestModal()):
68
+ if pr.number not in self.pull_requests:
69
+ self.pull_requests[pr.number] = pr
70
+ self.searchable_table.append_rows([pull_request_to_cell(pr)])
71
+
72
+ self.post_message(PullRequestSelected(pr))
73
+ lg.info(f"Looked up PR #{pr.number}")
74
+
53
75
  async def fetch_more_pull_requests(self, batch_size: int, batch_to_fetch: int) -> list[tuple[str | int, ...]]:
54
76
  if not LazyGithubContext.current_repo:
55
77
  return []
@@ -117,12 +139,64 @@ class PrOverviewTabPane(TabPane):
117
139
  PrOverviewTabPane {
118
140
  overflow-y: auto;
119
141
  }
142
+
143
+ Collapsible {
144
+ height: auto;
145
+ }
146
+
147
+ ListView {
148
+ height: auto;
149
+ }
120
150
  """
121
151
 
152
+ BINDINGS = [LazyGithubBindings.MERGE_PULL_REQUEST]
153
+
122
154
  def __init__(self, pr: FullPullRequest) -> None:
123
155
  super().__init__("Overview", id="overview_pane")
124
156
  self.pr = pr
125
157
 
158
+ def _status_check_to_label(self, status: CheckStatus) -> str:
159
+ match status.state:
160
+ case CheckStatusState.SUCCESS:
161
+ status_summary = f"[green]{CHECKMARK} Passed[/green]"
162
+ case CheckStatusState.PENDING:
163
+ status_summary = "[yellow]... Pending[/yellow]"
164
+ case CheckStatusState.FAILURE:
165
+ status_summary = f"[red]{X_MARK} Failed[/red]"
166
+ case CheckStatusState.ERROR:
167
+ status_summary = f"[red]{X_MARK} Errored[/red]"
168
+
169
+ return f"{status_summary} {status.context} - {status.description}"
170
+
171
+ async def action_merge_pull_request(self) -> None:
172
+ if self.pr.merged_at is not None:
173
+ self.notify("PR has already been merged!", title="Already Merged", severity="warning")
174
+ return
175
+
176
+ try:
177
+ merge_result = await merge_pull_request(
178
+ self.pr, LazyGithubContext.config.pull_requests.preferred_merge_method
179
+ )
180
+ if merge_result.merged:
181
+ lg.info(f"Merged PR {self.pr.number} in repo {self.pr.repo.full_name}")
182
+ self.notify(
183
+ f"Pull request {self.pr.number} merged. Note some cached information on the UI may be out of date.",
184
+ title="PR Merged",
185
+ )
186
+
187
+ # This will force refetch the updated information about the PR for the UI
188
+ self.post_message(PullRequestSelected(self.pr))
189
+ else:
190
+ lg.warning(f"Failed to merge PR {self.pr.number} in repo {self.pr.repo.full_name}")
191
+ self.notify(
192
+ f"Pull request {self.pr.number} could not be merged", title="Error Merging PR", severity="error"
193
+ )
194
+ except GithubApiRequestFailed:
195
+ lg.exception(f"Failed to merge PR {self.pr.number} in repo {self.pr.repo.full_name}")
196
+ self.notify(
197
+ f"Pull request {self.pr.number} could not be merged", title="Error Merging PR", severity="error"
198
+ )
199
+
126
200
  def compose(self) -> ComposeResult:
127
201
  pr_link = link(f"(#{self.pr.number})", self.pr.html_url)
128
202
  user_link = link(self.pr.user.login, self.pr.user.html_url)
@@ -157,10 +231,36 @@ class PrOverviewTabPane(TabPane):
157
231
  if self.pr.merged_at:
158
232
  date = self.pr.merged_at.strftime("%c")
159
233
  yield Label(f"\nMerged on {date}")
234
+ yield Rule()
235
+
236
+ # This is where we'll store information about the status checks being run on the PR
237
+ with Collapsible(title="Status Checks: ...", id="collapsible_status_checks") as c:
238
+ c.loading = True
239
+ # TODO: We should probably make this a table? That would allow follow-up actions to be performed as well
240
+ yield ListView(id="status_checks_list")
160
241
 
161
242
  yield Rule()
162
243
  yield Markdown(self.pr.body)
163
244
 
245
+ @work
246
+ async def load_checks(self) -> None:
247
+ # TODO: This should probably check normal check runs as well? Unsure if the combined check status includes all
248
+ # of those
249
+ combined_check_status = await combined_check_status_for_ref(self.pr.repo, self.pr.head.sha)
250
+ status_checks_list = self.query_one("#status_checks_list", ListView)
251
+ collapse_container = self.query_one("#collapsible_status_checks", Collapsible)
252
+ if statuses := combined_check_status.statuses:
253
+ status_labels = sorted(self._status_check_to_label(c) for c in statuses)
254
+ status_checks_list.extend(ListItem(Label(status_label)) for status_label in status_labels)
255
+ collapse_container.title = f"Status checks: {combined_check_status.state.value.title()}"
256
+ else:
257
+ collapse_container.title = "No status checks on PR"
258
+
259
+ collapse_container.loading = False
260
+
261
+ async def on_mount(self) -> None:
262
+ _ = self.load_checks()
263
+
164
264
 
165
265
  class PrDiffTabPane(TabPane):
166
266
  def __init__(self, pr: FullPullRequest) -> None:
@@ -172,7 +272,7 @@ class PrDiffTabPane(TabPane):
172
272
  yield RichLog(id="diff_contents", highlight=True)
173
273
 
174
274
  @work
175
- async def fetch_diff(self):
275
+ async def fetch_diff(self) -> None:
176
276
  diff_contents = self.query_one("#diff_contents", RichLog)
177
277
  try:
178
278
  diff = await get_diff(self.pr)
@@ -185,9 +285,9 @@ class PrDiffTabPane(TabPane):
185
285
  diff_contents.write(diff)
186
286
  self.loading = False
187
287
 
188
- def on_mount(self) -> None:
288
+ async def on_mount(self) -> None:
189
289
  self.loading = True
190
- self.fetch_diff()
290
+ _ = self.fetch_diff()
191
291
 
192
292
 
193
293
  class PrConversationTabPane(TabPane):
@@ -28,7 +28,6 @@ class ReposContainer(LazyGithubContainer):
28
28
  BINDINGS = [
29
29
  LazyGithubBindings.TOGGLE_FAVORITE_REPO,
30
30
  LazyGithubBindings.LOOKUP_REPOSITORY,
31
- # ("enter", "select"),
32
31
  ]
33
32
 
34
33
  def __init__(self, *args, **kwargs) -> None:
@@ -0,0 +1 @@
1
+ VERSION = "0.3.2"
@@ -1,44 +0,0 @@
1
- name: Publish the package to pypi
2
-
3
- on: workflow_dispatch
4
-
5
- jobs:
6
- pypi-publish:
7
- name: Upload release to PyPI
8
- runs-on: ubuntu-latest
9
- environment:
10
- name: pypi
11
- url: https://pypi.org/p/lazy-github
12
- permissions:
13
- id-token: write
14
- steps:
15
- # Perform a bunch of setup
16
- - uses: actions/checkout@v3
17
- - uses: actions/setup-python@v4
18
- - uses: yezz123/setup-uv@v4
19
-
20
- - name: Calculate version
21
- id: calculate-version
22
- run: |
23
- new_version=$((uvx hatch version))
24
- echo "version=${new_version}" >> $GITHUB_OUTPUT
25
-
26
- # Build the distribution
27
- - name: Build lazy-github distribution
28
- run: uvx hatch build
29
-
30
- - name: Publish package distributions to PyPI
31
- uses: pypa/gh-action-pypi-publish@release/v1
32
-
33
- - name: Create tag
34
- run: |
35
- tag_name="v${{ steps.calculate-version.outputs.version }}"
36
- git tag $tag_name && git push origin $tag_name
37
-
38
- - name: Notify when this workflow completes (regardless of success or failure)
39
- uses: nobrayner/discord-webhook@v1
40
- with:
41
- title: "Version ${{ steps.calculate-version.outputs.version }} published to PyPi"
42
- description: "Check out the new version [here](https://pypi.org/project/lazy-github/${{ steps.calculate-version.outputs.version }}/)"
43
- github-token: ${{ secrets.github_token }}
44
- discord-webhook: ${{ secrets.DISCORD_WEBHOOK }}
@@ -1 +0,0 @@
1
- VERSION = "0.3.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes