lazy-github 0.6.1__tar.gz → 0.6.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 (82) hide show
  1. {lazy_github-0.6.1 → lazy_github-0.6.2}/PKG-INFO +1 -1
  2. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/cli.py +15 -3
  3. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/bindings.py +7 -0
  4. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/backends/cli.py +1 -1
  5. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/issues.py +8 -0
  6. lazy_github-0.6.2/lazy_github/lib/github/reactions.py +41 -0
  7. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/messages.py +11 -3
  8. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/pr_drafts.py +1 -1
  9. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/models/github.py +42 -1
  10. lazy_github-0.6.2/lazy_github/ui/screens/lookup_issue.py +89 -0
  11. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/primary.py +4 -4
  12. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/conversations.py +89 -10
  13. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/issues.py +11 -3
  14. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/pull_requests.py +105 -29
  15. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/workflows.py +0 -2
  16. lazy_github-0.6.2/lazy_github/version.py +1 -0
  17. lazy_github-0.6.2/lint.sh +6 -0
  18. lazy_github-0.6.1/lazy_github/version.py +0 -1
  19. lazy_github-0.6.1/lint.sh +0 -6
  20. {lazy_github-0.6.1 → lazy_github-0.6.2}/.devcontainer/devcontainer.json +0 -0
  21. {lazy_github-0.6.1 → lazy_github-0.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {lazy_github-0.6.1 → lazy_github-0.6.2}/.github/workflows/code-checks.yaml +0 -0
  23. {lazy_github-0.6.1 → lazy_github-0.6.2}/.github/workflows/publish.yaml +0 -0
  24. {lazy_github-0.6.1 → lazy_github-0.6.2}/.gitignore +0 -0
  25. {lazy_github-0.6.1 → lazy_github-0.6.2}/.pre-commit-config.yaml +0 -0
  26. {lazy_github-0.6.1 → lazy_github-0.6.2}/.quicklinks/README.md +0 -0
  27. {lazy_github-0.6.1 → lazy_github-0.6.2}/.quicklinks/config.lua +0 -0
  28. {lazy_github-0.6.1 → lazy_github-0.6.2}/.quicklinks/queries/python/patterns/textual_imports.scm +0 -0
  29. {lazy_github-0.6.1 → lazy_github-0.6.2}/LICENSE +0 -0
  30. {lazy_github-0.6.1 → lazy_github-0.6.2}/README.md +0 -0
  31. {lazy_github-0.6.1 → lazy_github-0.6.2}/flake.lock +0 -0
  32. {lazy_github-0.6.1 → lazy_github-0.6.2}/flake.nix +0 -0
  33. {lazy_github-0.6.1 → lazy_github-0.6.2}/gh-lazy +0 -0
  34. {lazy_github-0.6.1 → lazy_github-0.6.2}/images/lazy-github-conversation-ui.svg +0 -0
  35. {lazy_github-0.6.1 → lazy_github-0.6.2}/images/lazy-github-settings-ui.png +0 -0
  36. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/__main__.py +0 -0
  37. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/cache.py +0 -0
  38. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/config.py +0 -0
  39. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/constants.py +0 -0
  40. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/context.py +0 -0
  41. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/debug.py +0 -0
  42. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/diff_parser.py +0 -0
  43. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/git_cli.py +0 -0
  44. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/auth.py +0 -0
  45. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/backends/hishel.py +0 -0
  46. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/backends/protocol.py +0 -0
  47. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/branches.py +0 -0
  48. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/checks.py +0 -0
  49. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/client.py +0 -0
  50. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/notifications.py +0 -0
  51. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/pull_requests.py +0 -0
  52. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/repositories.py +0 -0
  53. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/users.py +0 -0
  54. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/github/workflows.py +0 -0
  55. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/lib/logging.py +0 -0
  56. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/app.py +0 -0
  57. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/auth.py +0 -0
  58. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/confirm.py +0 -0
  59. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/create_or_edit_pull_request.py +1 -1
  60. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/debug.py +0 -0
  61. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/edit_issue.py +0 -0
  62. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/lookup_pull_request.py +0 -0
  63. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/lookup_repository.py +0 -0
  64. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/new_comment.py +0 -0
  65. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/new_issue.py +0 -0
  66. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/notifications.py +0 -0
  67. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/settings.py +0 -0
  68. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/screens/trigger_workflow.py +0 -0
  69. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/command_log.py +0 -0
  70. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/common.py +0 -0
  71. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/diff_viewer.py +0 -0
  72. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/info.py +0 -0
  73. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/repositories.py +0 -0
  74. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/split_diff_viewer.py +0 -0
  75. {lazy_github-0.6.1 → lazy_github-0.6.2}/lazy_github/ui/widgets/workflow_run_details.py +0 -0
  76. {lazy_github-0.6.1 → lazy_github-0.6.2}/pyproject.toml +0 -0
  77. {lazy_github-0.6.1 → lazy_github-0.6.2}/start.sh +0 -0
  78. {lazy_github-0.6.1 → lazy_github-0.6.2}/tests/__init__.py +0 -0
  79. {lazy_github-0.6.1 → lazy_github-0.6.2}/tests/test_cache.py +0 -0
  80. {lazy_github-0.6.1 → lazy_github-0.6.2}/tests/test_config.py +0 -0
  81. {lazy_github-0.6.1 → lazy_github-0.6.2}/tests/test_settings_ui.py +0 -0
  82. {lazy_github-0.6.1 → lazy_github-0.6.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazy-github
3
- Version: 0.6.1
3
+ Version: 0.6.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>
@@ -55,10 +55,14 @@ def clear_auth():
55
55
 
56
56
 
57
57
  @cli.command
58
- def clear_config():
58
+ @click.option("--no-confirm", is_flag=True, default=False, help="Don't ask for confirmation")
59
+ def clear_config(no_confirm: bool):
59
60
  """Reset the user's settings"""
60
- _CONFIG_FILE_LOCATION.unlink(missing_ok=True)
61
- print("Your settings have been cleared")
61
+ if no_confirm or click.confirm("Confirm deletion your LazyGithub settings"):
62
+ _CONFIG_FILE_LOCATION.unlink(missing_ok=True)
63
+ print("Your settings have been cleared")
64
+ else:
65
+ print("Canceling")
62
66
 
63
67
 
64
68
  @cli.command
@@ -80,3 +84,11 @@ def clear_cache(no_confirm: bool):
80
84
  def debug():
81
85
  """Outputs LazyGithub debug info"""
82
86
  print(collect_debug_info())
87
+
88
+
89
+ @cli.command
90
+ def version():
91
+ """Prints the current version"""
92
+ from lazy_github.version import VERSION
93
+
94
+ print(f"LazyGithub version {VERSION}")
@@ -38,6 +38,13 @@ class LazyGithubBindings:
38
38
  "I", "open_issue", "New Issue", id="issue.new", tooltip="Open a new issue in the current repository"
39
39
  )
40
40
  EDIT_ISSUE = Binding("E", "edit_issue", "Edit Issue", id="issue.edit", tooltip="Edit the selected issue")
41
+ LOOKUP_ISSUE = Binding(
42
+ "O",
43
+ "lookup_issue",
44
+ "Lookup Issue",
45
+ id="issue.lookup",
46
+ tooltip="Lookup an issue in the current repository",
47
+ )
41
48
 
42
49
  # Pull request actions
43
50
  OPEN_PULL_REQUEST = Binding(
@@ -101,7 +101,7 @@ def _create_request_body_tempfile(body: bytes) -> tempfile._TemporaryFileWrapper
101
101
  _TEMPORARY_JSON_BODY_DIRECTORY.mkdir(parents=True, exist_ok=True)
102
102
  if sys.version_info.minor > 11:
103
103
  # delete_on_close parameter added in Python 3.12
104
- temp = tempfile.NamedTemporaryFile(delete=False, delete_on_close=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY) # type: ignore[call-arg]
104
+ temp = tempfile.NamedTemporaryFile(delete=False, delete_on_close=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY)
105
105
  else:
106
106
  temp = tempfile.NamedTemporaryFile(delete=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY)
107
107
  temp.write(body)
@@ -7,6 +7,14 @@ from lazy_github.models.github import Issue, IssueComment, PartialPullRequest, R
7
7
  DEFAULT_PAGE_SIZE = 30
8
8
 
9
9
 
10
+ async def get_issue_by_number(repo: Repository, issue_number: int) -> Issue:
11
+ """Looks up a single issue by number in the given repository"""
12
+ url = f"/repos/{repo.owner.login}/{repo.name}/issues/{issue_number}"
13
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
14
+ response.raise_for_status()
15
+ return Issue(**response.json(), repo=repo)
16
+
17
+
10
18
  class UpdateIssuePayload(TypedDict):
11
19
  title: str | None
12
20
  body: str | None
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+ from lazy_github.lib.context import LazyGithubContext, github_headers
4
+ from lazy_github.models.github import Issue, IssueComment, Reaction, ReactionSet, ReactionType, Repository, User
5
+
6
+
7
+ def _build_reaction_set(response: Any) -> ReactionSet:
8
+ response.raise_for_status()
9
+ reaction_users: dict[ReactionType, list[User]] = {}
10
+ reaction_counts: dict[ReactionType, int] = {}
11
+
12
+ for raw_reaction in response.json():
13
+ reaction_type = ReactionType.from_github(raw_reaction["content"])
14
+ reaction_users.setdefault(reaction_type, [])
15
+ reaction_counts.setdefault(reaction_type, 0)
16
+
17
+ user = User(**raw_reaction["user"])
18
+ reaction_users[reaction_type].append(user)
19
+ reaction_counts[reaction_type] += 1
20
+
21
+ return ReactionSet(reaction_users=reaction_users, reaction_counts=reaction_counts)
22
+
23
+
24
+ async def list_reactions_on_comment(repo: Repository, comment: IssueComment) -> ReactionSet:
25
+ url = f"/repos/{repo.full_name}/issues/comments/{comment.id}/reactions"
26
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
27
+ return _build_reaction_set(response)
28
+
29
+
30
+ async def add_reaction_on_comment(repo: Repository, comment: IssueComment, reaction: ReactionType) -> Reaction:
31
+ url = f"/repos/{repo.full_name}/issues/comments/{comment.id}/reactions"
32
+ body = {"content": reaction.name.lower()}
33
+ response = await LazyGithubContext.client.post(url, headers=github_headers(), json=body)
34
+ response.raise_for_status()
35
+ return Reaction(**response.json())
36
+
37
+
38
+ async def list_reactions_on_issue(repo: Repository, issue: Issue) -> ReactionSet:
39
+ url = f"/repos/{repo.full_name}/issues/{issue.number}/reactions"
40
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
41
+ return _build_reaction_set(response)
@@ -9,18 +9,26 @@ from lazy_github.models.github import (
9
9
  IssueComment,
10
10
  Notification,
11
11
  PartialPullRequest,
12
+ ReactionSet,
12
13
  Repository,
13
14
  Review,
14
15
  WorkflowRun,
15
16
  )
16
17
 
17
18
 
18
- class ReviewsLoaded(Message):
19
- """A message sent when reviews for a pull request have been loaded"""
19
+ class ReviewsAndCommentsLoaded(Message):
20
+ """A message sent when reviews and comments for a pull request have been loaded"""
20
21
 
21
- def __init__(self, reviews: list[Review]) -> None:
22
+ def __init__(self, reviews: list[Review], comments: list[IssueComment]) -> None:
22
23
  super().__init__()
23
24
  self.reviews = reviews
25
+ self.comments = comments
26
+
27
+
28
+ class CommentReactionsLoaded(Message):
29
+ def __init__(self, reactions: dict[int, ReactionSet]) -> None:
30
+ super().__init__()
31
+ self.reactions = reactions
24
32
 
25
33
 
26
34
  class RepoSelected(Message):
@@ -1,5 +1,5 @@
1
1
  import json
2
- from dataclasses import dataclass, asdict
2
+ from dataclasses import asdict, dataclass
3
3
  from pathlib import Path
4
4
 
5
5
  from lazy_github.lib.context import LazyGithubContext
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime
2
- from enum import StrEnum
2
+ from enum import Enum, StrEnum
3
3
 
4
4
  from pydantic import BaseModel, Field
5
5
 
@@ -302,3 +302,44 @@ class CheckStatus(BaseModel):
302
302
  class CombinedCheckStatus(BaseModel):
303
303
  state: CheckStatusState
304
304
  statuses: list[CheckStatus]
305
+
306
+
307
+ class ReactionType(Enum):
308
+ def __init__(self, value: str, emoji: str) -> None:
309
+ super().__init__()
310
+ self._value = value
311
+ self.emoji = emoji
312
+
313
+ @classmethod
314
+ def from_github(cls, github_content: str) -> "ReactionType":
315
+ try:
316
+ return cls[github_content.upper()]
317
+ except KeyError:
318
+ if github_content == "+1":
319
+ return ReactionType.THUMBS_UP
320
+ elif github_content == "-1":
321
+ return ReactionType.THUMBS_DOWN
322
+ else:
323
+ raise ValueError(f"Invalid reaction string: {github_content}")
324
+
325
+ THUMBS_UP = ("+1", "👍")
326
+ THUMBS_DOWN = ("-1", "👎")
327
+ LAUGH = ("laugh", "😄")
328
+ CONFUSED = ("confused", "😕")
329
+ HEART = ("heart", "❤️")
330
+ HOORAY = ("hooray", "🎉")
331
+ ROCKET = ("rocket", "🚀")
332
+ EYES = ("eyes", "👀")
333
+
334
+
335
+ class Reaction(BaseModel):
336
+ reaction_type: ReactionType
337
+ user: User
338
+
339
+
340
+ class ReactionSet(BaseModel):
341
+ reaction_users: dict[ReactionType, list[User]]
342
+ reaction_counts: dict[ReactionType, int]
343
+
344
+ def __bool__(self) -> bool:
345
+ return bool(self.reaction_counts) or bool(self.reaction_users)
@@ -0,0 +1,89 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Container, Horizontal
4
+ from textual.content import Content
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Button, Input, Label, Markdown, Rule
7
+
8
+ from lazy_github.lib.bindings import LazyGithubBindings
9
+ from lazy_github.lib.context import LazyGithubContext
10
+ from lazy_github.lib.github.backends.protocol import GithubApiRequestFailed
11
+ from lazy_github.lib.github.issues import get_issue_by_number
12
+ from lazy_github.models.github import Issue
13
+ from lazy_github.ui.widgets.common import LazyGithubFooter
14
+
15
+
16
+ class LookupIssueButtons(Horizontal):
17
+ DEFAULT_CSS = """
18
+ LookupIssueButtons {
19
+ align: center middle;
20
+ height: auto;
21
+ width: 100%;
22
+ }
23
+ Button {
24
+ margin: 1;
25
+ }
26
+ """
27
+
28
+ def compose(self) -> ComposeResult:
29
+ yield Button("Open", id="lookup", variant="success")
30
+ yield Button("Cancel", id="cancel", variant="error")
31
+
32
+
33
+ class LookupIssueContainer(Container):
34
+ DEFAULT_CSS = """
35
+ LookupIssueContainer {
36
+ align: center middle;
37
+ }
38
+ """
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield Markdown("# Search for an issue by number:")
42
+ yield Label(Content.from_markup("[bold]Issue Number:[/bold]"))
43
+ yield Input(
44
+ id="issue_number",
45
+ placeholder="Issue number",
46
+ type="number",
47
+ )
48
+ yield Rule()
49
+ yield LookupIssueButtons()
50
+
51
+
52
+ class LookupIssueModal(ModalScreen[Issue | None]):
53
+ DEFAULT_CSS = """
54
+ LookupIssueModal {
55
+ align: center middle;
56
+ content-align: center middle;
57
+ }
58
+
59
+ LookupIssueContainer {
60
+ width: 60;
61
+ max-height: 25;
62
+ border: thick $background 80%;
63
+ background: $surface-lighten-3;
64
+ }
65
+ """
66
+
67
+ BINDINGS = [LazyGithubBindings.SUBMIT_DIALOG, LazyGithubBindings.CLOSE_DIALOG]
68
+
69
+ def compose(self) -> ComposeResult:
70
+ yield LookupIssueContainer()
71
+ yield LazyGithubFooter()
72
+
73
+ @on(Button.Pressed, "#lookup")
74
+ async def action_submit(self) -> None:
75
+ assert LazyGithubContext.current_repo is not None, "Current repo is missing!"
76
+
77
+ try:
78
+ issue_number = int(self.query_one("#issue_number", Input).value)
79
+ issue = await get_issue_by_number(LazyGithubContext.current_repo, issue_number)
80
+ except ValueError:
81
+ self.notify("Must enter a valid issue number!", title="Invalid Issue Number", severity="error")
82
+ except GithubApiRequestFailed:
83
+ self.notify("Could not find issue!", title="Unknown Issue", severity="error")
84
+ else:
85
+ self.dismiss(issue)
86
+
87
+ @on(Button.Pressed, "#cancel")
88
+ async def action_close(self) -> None:
89
+ self.dismiss(None)
@@ -30,7 +30,7 @@ from lazy_github.lib.messages import (
30
30
  IssueSelected,
31
31
  PullRequestSelected,
32
32
  RepoSelected,
33
- ReviewsLoaded,
33
+ ReviewsAndCommentsLoaded,
34
34
  WorkflowRunSelected,
35
35
  )
36
36
  from lazy_github.models.github import Issue, PartialPullRequest, Repository, WorkflowRun
@@ -333,11 +333,11 @@ class MainViewPane(Container):
333
333
  if focus_run_details:
334
334
  tabbed_content.children[0].focus()
335
335
 
336
- @on(ReviewsLoaded)
337
- async def handle_reviews_loaded(self, message: ReviewsLoaded) -> None:
336
+ @on(ReviewsAndCommentsLoaded)
337
+ async def handle_reviews_loaded(self, message: ReviewsAndCommentsLoaded) -> None:
338
338
  try:
339
339
  overview_pane = self.query_one(PrOverviewTabPane)
340
- except WrongType | NoMatches:
340
+ except (WrongType, NoMatches):
341
341
  pass
342
342
  else:
343
343
  overview_pane.add_reviews(message.reviews)
@@ -2,15 +2,77 @@ from textual import work
2
2
  from textual.app import ComposeResult
3
3
  from textual.containers import Container
4
4
  from textual.content import Content
5
- from textual.widgets import Collapsible, Label, Markdown
5
+ from textual.widgets import Collapsible, Label, ListItem, ListView, Markdown, Static
6
6
 
7
7
  from lazy_github.lib.bindings import LazyGithubBindings
8
8
  from lazy_github.lib.github.pull_requests import ReviewCommentNode
9
9
  from lazy_github.lib.messages import NewCommentCreated
10
- from lazy_github.models.github import FullPullRequest, Issue, IssueComment, Review, ReviewComment, ReviewState
10
+ from lazy_github.models.github import (
11
+ FullPullRequest,
12
+ Issue,
13
+ IssueComment,
14
+ ReactionSet,
15
+ Review,
16
+ ReviewComment,
17
+ ReviewState,
18
+ )
11
19
  from lazy_github.ui.screens.new_comment import NewCommentModal
12
20
 
13
21
 
22
+ class ReactionsDisplay(Container):
23
+ DEFAULT_CSS = """
24
+ ReactionsDisplay {
25
+ height: auto;
26
+ }
27
+ Collapsible {
28
+ height: auto;
29
+ }
30
+
31
+ ListView {
32
+ height: auto;
33
+ }
34
+ """
35
+
36
+ def __init__(self, item_id: str | int, id: str | None = None) -> None:
37
+ super().__init__(id=id)
38
+ self.item_id = item_id
39
+
40
+ @property
41
+ def reactions_list(self) -> ListView:
42
+ return self.query_one(f"#reactions_list_{self.item_id}", ListView)
43
+
44
+ @property
45
+ def collapsible_reactions(self) -> Collapsible:
46
+ return self.query_one(f"#collapsible_reactions_{self.item_id}", Collapsible)
47
+
48
+ def compose(self) -> ComposeResult:
49
+ with Collapsible(title="Reactions...", id=f"collapsible_reactions_{self.item_id}", collapsed=True):
50
+ yield ListView(id=f"reactions_list_{self.item_id}")
51
+
52
+ def on_mount(self) -> None:
53
+ self.loading = True
54
+
55
+ async def set_reactions(self, reactions: ReactionSet) -> None:
56
+ self.loading = True
57
+ await self.reactions_list.clear()
58
+ summary_strings = [f"{rt.emoji} {count}" for rt, count in reactions.reaction_counts.items() if count]
59
+
60
+ for reaction_type, users in reactions.reaction_users.items():
61
+ if not users:
62
+ continue
63
+ elif len(users) > 3:
64
+ users_string = f"{users[0].login}, {users[1].login}, {users[2].login}, and {len(users) - 3} more"
65
+ else:
66
+ users_string = ", ".join(u.login for u in users)
67
+
68
+ reaction_label = f"{reaction_type.emoji}: {users_string}"
69
+ self.reactions_list.append(ListItem(Label(Content.from_markup(reaction_label))))
70
+
71
+ self.loading = False
72
+ self.collapsible_reactions.title = " | ".join(summary_strings)
73
+ self.collapsible_reactions.display = bool(summary_strings)
74
+
75
+
14
76
  class IssueCommentContainer(Container, can_focus=True):
15
77
  DEFAULT_CSS = """
16
78
  IssueCommentContainer {
@@ -60,6 +122,15 @@ class IssueCommentContainer(Container, can_focus=True):
60
122
  def action_reply_to_individual_comment(self) -> None:
61
123
  self.reply_to_comment_flow()
62
124
 
125
+ async def add_reaction_display(self, reactions: ReactionSet) -> None:
126
+ # Add the reactions to the bottom of each comment display
127
+ if not reactions:
128
+ return
129
+
130
+ rd = ReactionsDisplay(self.comment.id)
131
+ await self.children[-1].mount(rd)
132
+ await rd.set_reactions(reactions)
133
+
63
134
 
64
135
  class ReviewConversation(Container):
65
136
  DEFAULT_CSS = """
@@ -86,12 +157,16 @@ class ReviewConversation(Container):
86
157
  yield IssueCommentContainer(self.pr, comment)
87
158
 
88
159
 
89
- class ReviewContainer(Collapsible, can_focus=True):
160
+ class ReviewContainer(Container):
90
161
  DEFAULT_CSS = """
91
162
  ReviewContainer {
92
163
  height: auto;
93
164
  }
94
165
 
166
+ Collapsible {
167
+ height: auto;
168
+ }
169
+
95
170
  ReviewContainer:focus-within {
96
171
  border: solid $success-lighten-3;
97
172
  }
@@ -111,15 +186,19 @@ class ReviewContainer(Collapsible, can_focus=True):
111
186
  review_state_text = "[red]Changes Requested[/red]"
112
187
  else:
113
188
  review_state_text = self.review.state.title()
189
+ review_summary = f"Review from {self.review.user.login} ({review_state_text})"
114
190
 
115
- yield Label(Content.from_markup(f"Review from {self.review.user.login} ({review_state_text})"))
116
-
117
- if self.review.body:
118
- yield Markdown(self.review.body)
191
+ if self.review.body or self.review.comments:
192
+ with Collapsible(title=review_summary, collapsed=self.review.state == ReviewState.DISMISSED):
193
+ if self.review.body:
194
+ yield Markdown(self.review.body)
119
195
 
120
- for comment in self.review.comments:
121
- if comment_node := self.hierarchy.get(comment.id):
122
- yield ReviewConversation(self.pr, comment_node)
196
+ for comment in self.review.comments:
197
+ if comment_node := self.hierarchy.get(comment.id):
198
+ yield ReviewConversation(self.pr, comment_node)
199
+ else:
200
+ with Collapsible(title=review_summary, collapsed=True):
201
+ yield Static("No additional review content")
123
202
 
124
203
  def action_reply_to_review(self) -> None:
125
204
  self.app.push_screen(NewCommentModal(self.pr.repo, self.pr, self.review))
@@ -15,6 +15,7 @@ from lazy_github.lib.logging import lg
15
15
  from lazy_github.lib.messages import IssuesAndPullRequestsFetched, IssueSelected, NewCommentCreated
16
16
  from lazy_github.models.github import Issue, IssueState, PartialPullRequest, Repository
17
17
  from lazy_github.ui.screens.edit_issue import EditIssueModal
18
+ from lazy_github.ui.screens.lookup_issue import LookupIssueModal
18
19
  from lazy_github.ui.screens.new_comment import NewCommentModal
19
20
  from lazy_github.ui.widgets.common import LazilyLoadedDataTable, LazyGithubContainer, TableRow
20
21
  from lazy_github.ui.widgets.conversations import IssueCommentContainer
@@ -25,7 +26,7 @@ def issue_to_cell(issue: Issue) -> TableRow:
25
26
 
26
27
 
27
28
  class IssuesContainer(LazyGithubContainer):
28
- BINDINGS = [LazyGithubBindings.EDIT_ISSUE]
29
+ BINDINGS = [LazyGithubBindings.LOOKUP_ISSUE, LazyGithubBindings.EDIT_ISSUE]
29
30
 
30
31
  issues: Dict[int, Issue] = {}
31
32
  status_column_index = -1
@@ -80,8 +81,6 @@ class IssuesContainer(LazyGithubContainer):
80
81
  self.number_column_index = self.table.get_column_index("number")
81
82
  self.title_column_index = self.table.get_column_index("title")
82
83
 
83
- self.searchable_table.loading = True
84
-
85
84
  def load_cached_issues_for_repo(self, repo: Repository) -> None:
86
85
  self.searchable_table.loading = True
87
86
  self.searchable_table.initialize_from_cache(repo, Issue)
@@ -112,6 +111,15 @@ class IssuesContainer(LazyGithubContainer):
112
111
  async def action_edit_issue(self) -> None:
113
112
  self.trigger_edit_issue_flow()
114
113
 
114
+ @work
115
+ async def action_lookup_issue(self) -> None:
116
+ if issue := await self.app.push_screen_wait(LookupIssueModal()):
117
+ if not self.searchable_table.item_in_table(issue):
118
+ self.searchable_table.add_item(issue)
119
+
120
+ self.post_message(IssueSelected(issue))
121
+ lg.info(f"Looked up Issue #{issue.number}")
122
+
115
123
  @on(DataTable.RowSelected, "#issues_table")
116
124
  async def issue_selected(self) -> None:
117
125
  issue = await self.get_selected_issue()
@@ -1,3 +1,6 @@
1
+ import asyncio
2
+ from typing import Any, Coroutine
3
+
1
4
  from httpx import HTTPStatusError
2
5
  from textual import on, work
3
6
  from textual.app import ComposeResult
@@ -28,16 +31,20 @@ from lazy_github.lib.github.pull_requests import (
28
31
  merge_pull_request,
29
32
  reconstruct_review_conversation_hierarchy,
30
33
  )
34
+ from lazy_github.lib.github.reactions import list_reactions_on_comment, list_reactions_on_issue
31
35
  from lazy_github.lib.logging import lg
32
36
  from lazy_github.lib.messages import (
37
+ CommentReactionsLoaded,
33
38
  IssuesAndPullRequestsFetched,
34
39
  PullRequestSelected,
35
- ReviewsLoaded,
40
+ ReviewsAndCommentsLoaded,
36
41
  )
37
42
  from lazy_github.models.github import (
38
43
  CheckStatus,
39
44
  FullPullRequest,
45
+ IssueComment,
40
46
  PartialPullRequest,
47
+ ReactionSet,
41
48
  Repository,
42
49
  Review,
43
50
  ReviewState,
@@ -52,7 +59,7 @@ from lazy_github.ui.widgets.common import (
52
59
  LazyGithubContainer,
53
60
  TableRow,
54
61
  )
55
- from lazy_github.ui.widgets.conversations import IssueCommentContainer, ReviewContainer
62
+ from lazy_github.ui.widgets.conversations import IssueCommentContainer, ReactionsDisplay, ReviewContainer
56
63
  from lazy_github.ui.widgets.diff_viewer import DiffViewerContainer
57
64
 
58
65
 
@@ -145,8 +152,6 @@ class PullRequestsContainer(LazyGithubContainer):
145
152
  self.number_column_index = self.table.get_column_index("number")
146
153
  self.title_column_index = self.table.get_column_index("title")
147
154
 
148
- self.searchable_table.loading = True
149
-
150
155
  async def on_issues_and_pull_requests_fetched(self, message: IssuesAndPullRequestsFetched) -> None:
151
156
  message.stop()
152
157
 
@@ -197,6 +202,24 @@ class PrOverviewTabPane(TabPane):
197
202
  super().__init__("Overview", id="overview_pane")
198
203
  self.pr = pr
199
204
 
205
+ @property
206
+ def collapsible_status_checks(self) -> Collapsible:
207
+ return self.query_one("#collapsible_status_checks", Collapsible)
208
+
209
+ @property
210
+ def collapsible_reviews(self) -> Collapsible:
211
+ return self.query_one("#collapsible_reviews", Collapsible)
212
+
213
+ @property
214
+ def pr_reactions(self) -> ReactionsDisplay:
215
+ return self.query_one("#pr_reactions", ReactionsDisplay)
216
+
217
+ def on_mount(self) -> None:
218
+ self.collapsible_status_checks.loading = True
219
+ self.collapsible_reviews.loading = True
220
+ self.load_checks()
221
+ self.load_reactions()
222
+
200
223
  def _status_check_to_label(self, status: CheckStatus) -> str:
201
224
  status_summary = status.state.to_display()
202
225
  return f"{status_summary} {status.context} - {status.description}"
@@ -280,30 +303,24 @@ class PrOverviewTabPane(TabPane):
280
303
  merged_date = self.pr.merged_at.strftime("%c")
281
304
  date_text += f" • Merged on {merged_date}"
282
305
  yield Label(Content.from_markup(date_text))
283
- yield Rule()
284
306
 
285
- # This is where we'll store information about the status checks being run on the PR
307
+ yield ReactionsDisplay(self.pr.id, id="pr_reactions")
308
+
286
309
  with Collapsible(title="Status Checks: ...", id="collapsible_status_checks"):
287
310
  yield ListView(id="status_checks_list")
288
311
 
289
- # This is where we'll store information about the PR reviews
290
312
  with Collapsible(title="Reviews: ...", id="collapsible_reviews"):
291
313
  yield ListView(id="reviews_list")
292
314
 
293
315
  yield Rule()
294
316
  yield Markdown(self.pr.body)
295
317
 
296
- def on_mount(self) -> None:
297
- self.query_one("#collapsible_status_checks", Collapsible).loading = True
298
- self.query_one("#collapsible_reviews", Collapsible).loading = True
299
- self.load_checks()
300
-
301
318
  @work
302
319
  async def add_reviews(self, reviews: list[Review]) -> None:
303
- reviews_collapsible_container = self.query_one("#collapsible_reviews", Collapsible)
304
320
  if not reviews:
305
- reviews_collapsible_container.title = "No reviews"
306
- reviews_collapsible_container.loading = False
321
+ self.collapsible_reviews.title = "No reviews"
322
+ self.collapsible_reviews.display = False
323
+ self.collapsible_reviews.loading = False
307
324
  return
308
325
 
309
326
  reviews_list = self.query_one("#reviews_list", ListView)
@@ -323,12 +340,21 @@ class PrOverviewTabPane(TabPane):
323
340
  reviews_list.extend(ListItem(Label(Content.from_markup(self._review_to_label(r)))) for r in latest_reviews)
324
341
 
325
342
  if all(r.state == ReviewState.APPROVED for r in latest_reviews):
326
- reviews_collapsible_container.title = f"PR Reviews: {ReviewState.APPROVED.to_display()}"
343
+ self.collapsible_reviews.title = f"PR Reviews: {ReviewState.APPROVED.to_display()}"
327
344
  elif any(r.state == ReviewState.CHANGES_REQUESTED for r in latest_reviews):
328
- reviews_collapsible_container.title = f"PR Reviews: {ReviewState.CHANGES_REQUESTED.to_display()}"
345
+ self.collapsible_reviews.title = f"PR Reviews: {ReviewState.CHANGES_REQUESTED.to_display()}"
329
346
  else:
330
- reviews_collapsible_container.title = "PR Reviews Summary"
331
- reviews_collapsible_container.loading = False
347
+ self.collapsible_reviews.title = "PR Reviews Summary"
348
+ self.collapsible_reviews.loading = False
349
+ self.collapsible_reviews.display = True
350
+
351
+ @work
352
+ async def load_reactions(self) -> None:
353
+ try:
354
+ reactions = await list_reactions_on_issue(self.pr.repo, self.pr)
355
+ await self.pr_reactions.set_reactions(reactions)
356
+ except Exception as e:
357
+ lg.exception(f"Error loading reactions data: {e}")
332
358
 
333
359
  @work
334
360
  async def load_checks(self) -> None:
@@ -336,18 +362,19 @@ class PrOverviewTabPane(TabPane):
336
362
  # of those
337
363
  combined_check_status = await combined_check_status_for_ref(self.pr.repo, self.pr.head.sha)
338
364
  status_checks_list = self.query_one("#status_checks_list", ListView)
339
- collapse_container = self.query_one("#collapsible_status_checks", Collapsible)
340
365
  if statuses := combined_check_status.statuses:
341
366
  status_labels = sorted(self._status_check_to_label(c) for c in statuses)
342
367
  status_checks_list.extend(
343
368
  ListItem(Label(Content.from_markup(status_label))) for status_label in status_labels
344
369
  )
345
370
 
346
- collapse_container.title = f"Status checks: {combined_check_status.state.to_display()}"
371
+ self.collapsible_status_checks.title = f"Status checks: {combined_check_status.state.to_display()}"
372
+ self.collapsible_status_checks.display = True
347
373
  else:
348
- collapse_container.title = "No status checks on PR"
374
+ self.collapsible_status_checks.title = "No status checks on PR"
375
+ self.collapsible_status_checks.display = False
349
376
 
350
- collapse_container.loading = False
377
+ self.collapsible_status_checks.loading = False
351
378
 
352
379
 
353
380
  class PrDiffTabPane(TabPane):
@@ -385,6 +412,7 @@ class PrConversationTabPane(TabPane):
385
412
  def __init__(self, pr: FullPullRequest) -> None:
386
413
  super().__init__("Conversation", id="conversation_pane")
387
414
  self.pr = pr
415
+ self.comment_containers: dict[str, IssueCommentContainer] = {}
388
416
 
389
417
  def compose(self) -> ComposeResult:
390
418
  yield VerticalScroll(id="pr_comments_and_reviews")
@@ -393,33 +421,81 @@ class PrConversationTabPane(TabPane):
393
421
  def comments_and_reviews(self) -> VerticalScroll:
394
422
  return self.query_one("#pr_comments_and_reviews", VerticalScroll)
395
423
 
396
- @on(ReviewsLoaded)
397
- async def handle_reviews_loaded(self, message: ReviewsLoaded) -> None:
424
+ @on(ReviewsAndCommentsLoaded)
425
+ async def handle_reviews_loaded(self, message: ReviewsAndCommentsLoaded) -> None:
398
426
  review_hierarchy = reconstruct_review_conversation_hierarchy(message.reviews)
399
- comments = await get_comments(self.pr)
400
427
  self.comments_and_reviews.remove_children()
401
428
 
402
429
  handled_comment_node_ids: list[int] = []
403
430
  for review in message.reviews:
431
+ if not review.body and review.state == ReviewState.COMMENTED:
432
+ continue
404
433
  if review.body:
405
434
  handled_comment_node_ids.extend([c.id for c in review.comments])
406
435
  review_container = ReviewContainer(self.pr, review, review_hierarchy)
407
436
  self.comments_and_reviews.mount(review_container)
408
437
 
409
- for comment in comments:
438
+ for comment in message.comments:
410
439
  if comment.body and comment.id not in handled_comment_node_ids:
411
440
  comment_container = IssueCommentContainer(self.pr, comment)
412
441
  self.comments_and_reviews.mount(comment_container)
413
442
 
414
443
  if len(self.comments_and_reviews.children) == 0:
415
444
  self.comments_and_reviews.mount(Label("No reviews or comments available"))
445
+ self.comments_and_reviews.display = False
446
+ else:
447
+ self.comments_and_reviews.display = True
448
+
449
+ self.comment_containers: dict[str, IssueCommentContainer] = {}
450
+ for container in self.query(IssueCommentContainer):
451
+ self.comment_containers[str(container.comment.id)] = container
452
+
453
+ self.fetch_reactions(self.pr.repo, message.reviews, message.comments)
416
454
 
417
455
  self.loading = False
418
456
 
457
+ @on(CommentReactionsLoaded)
458
+ async def handle_comment_reactions_loaded(self, message: CommentReactionsLoaded) -> None:
459
+ tasks: list[Coroutine[Any, Any, None]] = []
460
+ for comment_id, reactions in message.reactions.items():
461
+ if comment := self.comment_containers.get(str(comment_id)):
462
+ tasks.append(comment.add_reaction_display(reactions))
463
+
464
+ await asyncio.gather(*tasks)
465
+
466
+ @work
467
+ async def fetch_reactions(self, repo: Repository, reviews: list[Review], comments: list[IssueComment]) -> None:
468
+ """
469
+ Loads any reactions on the specified comments or on comments within the specified reviews. Posts a
470
+ `CommentReactionsLoaded` message after completion.
471
+ """
472
+ comment_reactions: dict[int, ReactionSet] = {}
473
+
474
+ async def _get_comment_reactions(comment: IssueComment) -> None:
475
+ try:
476
+ comment_reactions[comment.id] = await list_reactions_on_comment(repo, comment)
477
+ except GithubApiRequestFailed:
478
+ lg.debug(f"Could not find comment with ID {comment.id}")
479
+
480
+ tasks: list[Coroutine[Any, Any, None]] = []
481
+ tasks.extend(_get_comment_reactions(comment) for comment in comments)
482
+ for review in reviews:
483
+ tasks.extend(_get_comment_reactions(comment) for comment in review.comments)
484
+
485
+ await asyncio.gather(*tasks)
486
+ self.post_message(CommentReactionsLoaded(comment_reactions))
487
+
419
488
  @work
420
489
  async def fetch_conversation(self) -> None:
421
- reviews = await get_reviews(self.pr)
422
- self.post_message(ReviewsLoaded(reviews))
490
+ reviews_coro = get_reviews(self.pr)
491
+ comments_coro = get_comments(self.pr)
492
+
493
+ try:
494
+ reviews, comments = await reviews_coro, await comments_coro
495
+ except GithubApiRequestFailed:
496
+ lg.error("Error retrieving PR reviews or comments")
497
+ else:
498
+ self.post_message(ReviewsAndCommentsLoaded(reviews, comments))
423
499
 
424
500
  def on_mount(self) -> None:
425
501
  self.loading = True
@@ -59,8 +59,6 @@ class WorkflowRunsContainer(Container):
59
59
 
60
60
  self.run_number_column_id = self.table.get_column_index("run_number")
61
61
 
62
- self.searchable_table.loading = True
63
-
64
62
  def load_cached_workflow_runs(self, repo: Repository) -> None:
65
63
  self.searchable_table.loading = True
66
64
  self.searchable_table.initialize_from_cache(repo, WorkflowRun)
@@ -0,0 +1 @@
1
+ VERSION = "0.6.2"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+
3
+ uv sync --quiet
4
+ uvx ruff check --select I --fix --quiet
5
+ uvx ruff check --fix --quiet
6
+ uvx ty check --quiet
@@ -1 +0,0 @@
1
- VERSION = "0.6.1"
lazy_github-0.6.1/lint.sh DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env bash
2
-
3
- uv sync
4
- uv run ruff check --select I --fix
5
- uv run ruff check --fix
6
- uv run pyright
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -18,7 +18,6 @@ from lazy_github.lib.git_cli import (
18
18
  push_branch_to_remote,
19
19
  )
20
20
  from lazy_github.lib.github.branches import list_branches
21
- from lazy_github.lib.pr_drafts import PullRequestDraft, clear_pr_draft, load_pr_draft, save_pr_draft
22
21
  from lazy_github.lib.github.pull_requests import (
23
22
  create_pull_request,
24
23
  list_requested_reviewers,
@@ -29,6 +28,7 @@ from lazy_github.lib.github.repositories import get_collaborators
29
28
  from lazy_github.lib.github.users import get_user_by_username
30
29
  from lazy_github.lib.logging import lg
31
30
  from lazy_github.lib.messages import BranchesLoaded, PullRequestCreatedOrUpdated
31
+ from lazy_github.lib.pr_drafts import PullRequestDraft, clear_pr_draft, load_pr_draft, save_pr_draft
32
32
  from lazy_github.models.github import Branch, FullPullRequest, PartialPullRequest
33
33
  from lazy_github.ui.screens.confirm import ConfirmDialog
34
34
 
File without changes
File without changes
File without changes