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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- 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)
|