diffbutler 0.5.0__py3-none-any.whl

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 (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,119 @@
1
+ """Accessible PR search uses the same read/follow API as the CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.binding import BindingType
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import DataTable, Footer, Input, Static
11
+
12
+ from diffbutler.core.application import browse_configured, follow_configured
13
+ from diffbutler.domain.forge import PRQuery, PullRequest
14
+
15
+
16
+ class BrowseScreen(ModalScreen[str | None]):
17
+ BINDINGS: ClassVar[list[BindingType]] = [
18
+ ("escape", "dismiss(None)", "Back"),
19
+ ("enter", "follow", "Follow selected"),
20
+ ("n", "next_page", "Next page"),
21
+ ("f", "forge", "Next forge"),
22
+ ]
23
+ CSS = "BrowseScreen { padding: 2 3; } DataTable { height: 1fr; }"
24
+
25
+ def __init__(self, path: Path | None, forge_count: int) -> None:
26
+ super().__init__()
27
+ self.path = path
28
+ self.forge_count = forge_count
29
+ self.forge_index = 1
30
+ self.cursor: str | None = None
31
+ self.prs: dict[str, PullRequest] = {}
32
+ self.busy = False
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield Static(
36
+ "Browse accessible PRs · search with Enter, then select and Enter to follow",
37
+ markup=False,
38
+ )
39
+ yield Input(placeholder="Search text (blank lists accessible open PRs)")
40
+ yield Static("", id="browse-status", markup=False)
41
+ yield DataTable[Text](cursor_type="row")
42
+ yield Footer()
43
+
44
+ async def on_mount(self) -> None:
45
+ table = self.query_one(DataTable[Text])
46
+ table.add_columns("Repository", "#", "Title", "Author")
47
+ self.query_one(Input).focus()
48
+
49
+ def on_input_submitted(self, event: Input.Submitted) -> None:
50
+ event.stop()
51
+ self.cursor = None
52
+ self.run_worker(self.search(), exclusive=True, exit_on_error=False)
53
+
54
+ async def search(self) -> None:
55
+ if self.busy:
56
+ return
57
+ self.busy = True
58
+ status = self.query_one("#browse-status", Static)
59
+ try:
60
+ page = await browse_configured(
61
+ self.path, self.forge_index, PRQuery(text=self.query_one(Input).value), self.cursor
62
+ )
63
+ self.cursor = page.next_cursor
64
+ self.prs = {pr.url: pr for pr in page.items}
65
+ table = self.query_one(DataTable[Text])
66
+ table.clear()
67
+ for key, pr in self.prs.items():
68
+ values = (
69
+ f"{pr.ref.namespace}/{pr.ref.repository}",
70
+ str(pr.ref.number),
71
+ pr.title,
72
+ pr.author,
73
+ )
74
+ table.add_row(
75
+ *(
76
+ Text("".join(c if c.isprintable() else " " for c in value))
77
+ for value in values
78
+ ),
79
+ key=key,
80
+ )
81
+ status.update(
82
+ f"Forge {self.forge_index} · {len(page.items)} PRs · "
83
+ + ("Next page: n" if self.cursor else "Last page")
84
+ + (" · incomplete results; retry search" if not page.complete else "")
85
+ )
86
+ table.focus()
87
+ except Exception:
88
+ status.update("Search unavailable; check forge configuration and retry.")
89
+ finally:
90
+ self.busy = False
91
+
92
+ def action_next_page(self) -> None:
93
+ if self.cursor and not self.busy:
94
+ self.run_worker(self.search(), exit_on_error=False)
95
+
96
+ async def action_forge(self) -> None:
97
+ self.forge_index = self.forge_index % max(1, self.forge_count) + 1
98
+ self.cursor = None
99
+ await self.search()
100
+
101
+ async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
102
+ event.stop()
103
+ await self.action_follow()
104
+
105
+ async def action_follow(self) -> None:
106
+ if self.busy or isinstance(self.focused, Input):
107
+ return
108
+ table = self.query_one(DataTable[Text])
109
+ if not table.row_count:
110
+ return
111
+ key = str(table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value)
112
+ self.busy = True
113
+ try:
114
+ row = await follow_configured(self.path, self.forge_index, key)
115
+ self.dismiss(str(row.tracked.task_id))
116
+ except Exception:
117
+ self.query_one("#browse-status", Static).update("Could not follow this PR; retry.")
118
+ finally:
119
+ self.busy = False
@@ -0,0 +1,42 @@
1
+ """Browser dispatch stays at the UI boundary, with a copyable fallback URL."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ import webbrowser
7
+ from urllib.parse import urlsplit
8
+
9
+
10
+ async def open_browser(url: str, command: list[str] | None = None) -> bool:
11
+ try:
12
+ parsed = urlsplit(url)
13
+ except ValueError:
14
+ return False
15
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
16
+ return False
17
+ if command:
18
+ process = await asyncio.create_subprocess_exec(
19
+ *command,
20
+ url,
21
+ stdin=asyncio.subprocess.DEVNULL,
22
+ stdout=asyncio.subprocess.DEVNULL,
23
+ stderr=asyncio.subprocess.DEVNULL,
24
+ )
25
+ try:
26
+ async with asyncio.timeout(10):
27
+ return await process.wait() == 0
28
+ except BaseException:
29
+ if process.returncode is None:
30
+ process.kill()
31
+ await process.wait()
32
+ raise
33
+ if os.environ.get("SSH_CONNECTION") or (
34
+ sys.platform != "darwin"
35
+ and not (
36
+ os.environ.get("BROWSER")
37
+ or os.environ.get("DISPLAY")
38
+ or os.environ.get("WAYLAND_DISPLAY")
39
+ )
40
+ ):
41
+ return False
42
+ return await asyncio.to_thread(webbrowser.open, url)