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,926 @@
|
|
|
1
|
+
"""Textual dashboard over the same configured core used by the CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Awaitable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from rich.cells import cell_len
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import BindingType
|
|
15
|
+
from textual.containers import VerticalScroll
|
|
16
|
+
from textual.coordinate import Coordinate
|
|
17
|
+
from textual.events import Resize
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.widgets import Button, DataTable, Footer, Header, Input, Static
|
|
20
|
+
|
|
21
|
+
from diffbutler.config.settings import ConfigError, Settings
|
|
22
|
+
from diffbutler.core.dashboard import (
|
|
23
|
+
CycleDetail,
|
|
24
|
+
DashboardRow,
|
|
25
|
+
HistoryFilter,
|
|
26
|
+
Poller,
|
|
27
|
+
SortSpec,
|
|
28
|
+
cancel_task_configured,
|
|
29
|
+
column_value,
|
|
30
|
+
dashboard_rows,
|
|
31
|
+
detail_configured,
|
|
32
|
+
diff_configured,
|
|
33
|
+
history_configured,
|
|
34
|
+
log_configured,
|
|
35
|
+
pause_configured,
|
|
36
|
+
poll_configured,
|
|
37
|
+
preferences_configured,
|
|
38
|
+
save_sort_configured,
|
|
39
|
+
sort_rows,
|
|
40
|
+
state_configured,
|
|
41
|
+
toggle_monitoring_configured,
|
|
42
|
+
)
|
|
43
|
+
from diffbutler.core.managed import (
|
|
44
|
+
approved_configured,
|
|
45
|
+
cleanup_configured,
|
|
46
|
+
handoff_configured,
|
|
47
|
+
reset_task_configured,
|
|
48
|
+
review_configured,
|
|
49
|
+
reviewer_choices_configured,
|
|
50
|
+
)
|
|
51
|
+
from diffbutler.core.native import foreground_environment
|
|
52
|
+
from diffbutler.domain.dashboard import CycleRecord
|
|
53
|
+
from diffbutler.domain.review import ReviewConflict
|
|
54
|
+
from diffbutler.forges.base import ForgeError
|
|
55
|
+
from diffbutler.reviewers.process import HostBootError
|
|
56
|
+
from diffbutler.sources.git import SourceError
|
|
57
|
+
from diffbutler.ui.tui.app import TerminalApp
|
|
58
|
+
from diffbutler.ui.tui.browse import BrowseScreen
|
|
59
|
+
from diffbutler.ui.tui.browser import open_browser
|
|
60
|
+
from diffbutler.ui.tui.foreground import run_foreground
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def safe(value: object) -> str:
|
|
64
|
+
return "".join(char if char.isprintable() or char == "\n" else " " for char in str(value))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def queue_widths(
|
|
68
|
+
ids: list[str], labels: list[str], data: list[tuple[str, tuple[str, ...]]], width: int
|
|
69
|
+
) -> list[int]:
|
|
70
|
+
"""Fit columns in terminal cells, reserving the remaining space for the title."""
|
|
71
|
+
widths = [
|
|
72
|
+
max(cell_len(label), *(cell_len(cells[index]) for _, cells in data))
|
|
73
|
+
if data
|
|
74
|
+
else cell_len(label)
|
|
75
|
+
for index, label in enumerate(labels)
|
|
76
|
+
]
|
|
77
|
+
if "author" in ids:
|
|
78
|
+
index = ids.index("author")
|
|
79
|
+
widths[index] = min(16, widths[index])
|
|
80
|
+
available = max(80, width) - 2 * len(ids)
|
|
81
|
+
if "title" in ids:
|
|
82
|
+
index = ids.index("title")
|
|
83
|
+
widths[index] = max(1, available - sum(widths) + widths[index])
|
|
84
|
+
while sum(widths) > available:
|
|
85
|
+
index = widths.index(max(widths))
|
|
86
|
+
widths[index] -= 1
|
|
87
|
+
return widths
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def queue_cell(value: str, column: str, width: int) -> Text:
|
|
91
|
+
value = safe(value).replace("\n", " ")
|
|
92
|
+
if column == "author" and len(value) > 15:
|
|
93
|
+
value = value[:15] + "…"
|
|
94
|
+
text = Text(value, no_wrap=True, overflow="ellipsis")
|
|
95
|
+
text.truncate(width, overflow="ellipsis")
|
|
96
|
+
return text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def detail_metadata(detail: CycleDetail) -> list[str]:
|
|
100
|
+
cycle = detail.cycle
|
|
101
|
+
run = cycle.runs[-1] if cycle.runs else None
|
|
102
|
+
result = next((result for result in detail.results if run and result.run_id == run.id), None)
|
|
103
|
+
findings = str(len(result.document.findings)) if result and result.document else "—"
|
|
104
|
+
if result and result.markdown is not None:
|
|
105
|
+
findings = "Read review drafts"
|
|
106
|
+
age_hours = (datetime.now(UTC) - cycle.pull_request.created_at).total_seconds() / 3600
|
|
107
|
+
return [
|
|
108
|
+
f"AI Status: {run.status if run else 'Not started'}",
|
|
109
|
+
*([f"Reason: {run.status_reason}"] if run and run.status_reason else []),
|
|
110
|
+
f"Findings: {findings}",
|
|
111
|
+
f"Age: {age_hours:.0f}h",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def describe(detail: CycleDetail) -> str:
|
|
116
|
+
cycle = detail.cycle
|
|
117
|
+
pr = cycle.pull_request
|
|
118
|
+
lines = [
|
|
119
|
+
f"{pr.ref.namespace}/{pr.ref.repository}#{pr.ref.number} · cycle {cycle.cycle}",
|
|
120
|
+
pr.title,
|
|
121
|
+
pr.url,
|
|
122
|
+
*detail_metadata(detail),
|
|
123
|
+
f"Current/saved head: {pr.head_sha}",
|
|
124
|
+
f"Completion: {cycle.completion_reason or 'unfinished'} · {cycle.completed_at or ''}",
|
|
125
|
+
f"Cleanup: {cycle.cleanup_status} · {cycle.cleanup_error or ''}",
|
|
126
|
+
]
|
|
127
|
+
for session in cycle.sessions:
|
|
128
|
+
lines.append(f"Session: {session.display_name} · {session.status}")
|
|
129
|
+
for run in cycle.runs:
|
|
130
|
+
lines.append(f"\nReviewed {run.head_sha} · {run.reviewer_id} · {run.status}")
|
|
131
|
+
if run.status_reason:
|
|
132
|
+
lines.append(f"Reason: {run.status_reason}")
|
|
133
|
+
result = next((result for result in detail.results if result.run_id == run.id), None)
|
|
134
|
+
if result is None:
|
|
135
|
+
continue
|
|
136
|
+
if result.error:
|
|
137
|
+
lines.append(result.error)
|
|
138
|
+
if result.markdown is not None:
|
|
139
|
+
lines.extend(["\nReview drafts:", result.markdown])
|
|
140
|
+
if result.document:
|
|
141
|
+
orientation = result.document.orientation
|
|
142
|
+
lines.extend(["\nIntent: " + orientation.intent, "Shape: " + orientation.shape])
|
|
143
|
+
for location in orientation.look_here:
|
|
144
|
+
lines.append(f"Look here: {location.path}:{location.line} · {location.why}")
|
|
145
|
+
lines.extend("Low risk: " + item for item in orientation.low_risk)
|
|
146
|
+
lines.extend("Unverified: " + item for item in orientation.unverified)
|
|
147
|
+
if not result.document.findings:
|
|
148
|
+
lines.append("No issues found — ready for your human review.")
|
|
149
|
+
for finding in result.document.findings:
|
|
150
|
+
lines.extend(
|
|
151
|
+
[
|
|
152
|
+
f"\n{finding.severity} · {finding.path}:{finding.line}",
|
|
153
|
+
finding.title,
|
|
154
|
+
finding.body,
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
if cycle.cleanup_status == "succeeded":
|
|
158
|
+
lines.append(
|
|
159
|
+
"\nTemporary sources, native session and raw output deleted; analysis retained."
|
|
160
|
+
)
|
|
161
|
+
for receipt in cycle.receipts:
|
|
162
|
+
lines.append(
|
|
163
|
+
f"\nForge {receipt.id} · {receipt.author} · {receipt.created_at}\n{receipt.body}"
|
|
164
|
+
)
|
|
165
|
+
return safe("\n".join(lines))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def preview_summary(detail: CycleDetail) -> str:
|
|
169
|
+
cycle = detail.cycle
|
|
170
|
+
pr = cycle.pull_request
|
|
171
|
+
lines = [
|
|
172
|
+
f"#{pr.ref.number} {pr.title}",
|
|
173
|
+
f"Repository: {pr.ref.namespace}/{pr.ref.repository}",
|
|
174
|
+
*detail_metadata(detail),
|
|
175
|
+
]
|
|
176
|
+
run = cycle.runs[-1] if cycle.runs else None
|
|
177
|
+
result = next((result for result in detail.results if run and result.run_id == run.id), None)
|
|
178
|
+
if result and result.markdown is not None:
|
|
179
|
+
lines.append("Review ready · Enter to read drafts · c to check findings")
|
|
180
|
+
lines.append(result.markdown)
|
|
181
|
+
elif result and result.document:
|
|
182
|
+
doc = result.document
|
|
183
|
+
lines.append(
|
|
184
|
+
"No issues found — your turn."
|
|
185
|
+
if not doc.findings
|
|
186
|
+
else f"{len(doc.findings)} findings · c to check findings"
|
|
187
|
+
)
|
|
188
|
+
lines.append("Intent: " + doc.orientation.intent)
|
|
189
|
+
lines.extend(
|
|
190
|
+
f"Look here: {item.path}:{item.line} · {item.why}" for item in doc.orientation.look_here
|
|
191
|
+
)
|
|
192
|
+
lines.extend("Unverified: " + item for item in doc.orientation.unverified)
|
|
193
|
+
lines.extend(
|
|
194
|
+
f"{item.severity}: {item.title} · {item.path}:{item.line}" for item in doc.findings
|
|
195
|
+
)
|
|
196
|
+
elif result and result.error:
|
|
197
|
+
lines.append(result.error)
|
|
198
|
+
if run:
|
|
199
|
+
lines.append(f"Analyzed {run.head_sha[:12]} · current {pr.head_sha[:12]} · {run.status}")
|
|
200
|
+
session = next(
|
|
201
|
+
(session for session in cycle.sessions if session.id == run.session_id), None
|
|
202
|
+
)
|
|
203
|
+
if session:
|
|
204
|
+
lines.append("Session: " + session.display_name)
|
|
205
|
+
if cycle.opened_at:
|
|
206
|
+
lines.append("Opened for manual review · " + cycle.opened_at.isoformat())
|
|
207
|
+
lines.append("Enter for full orientation, findings, receipts and cleanup status.")
|
|
208
|
+
return safe("\n".join(lines))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class DetailScreen(ModalScreen[None]):
|
|
212
|
+
BINDINGS: ClassVar[list[BindingType]] = [("escape", "dismiss", "Back")]
|
|
213
|
+
CSS = "DetailScreen { padding: 2 4; } VerticalScroll { background: $surface; padding: 1 2; }"
|
|
214
|
+
|
|
215
|
+
def __init__(self, content: str) -> None:
|
|
216
|
+
super().__init__()
|
|
217
|
+
self.content = content
|
|
218
|
+
|
|
219
|
+
def compose(self) -> ComposeResult:
|
|
220
|
+
with VerticalScroll():
|
|
221
|
+
yield Static(Text(self.content))
|
|
222
|
+
yield Footer()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Dashboard(TerminalApp):
|
|
226
|
+
TITLE = "DiffButler"
|
|
227
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
228
|
+
("o", "open_in_browser", "Open PR"),
|
|
229
|
+
("w", "mark_waiting_for_author", "Wait for author"),
|
|
230
|
+
("r", "run_review", "Review"),
|
|
231
|
+
("n", "next_reviewer", "Next reviewer"),
|
|
232
|
+
("c", "resume_session", "Check findings"),
|
|
233
|
+
("m", "mark_approved", "Mark approved"),
|
|
234
|
+
("k", "reset_pr", "Reset PR"),
|
|
235
|
+
("h", "view_history", "History"),
|
|
236
|
+
("b", "browse_prs", "Browse all PRs"),
|
|
237
|
+
("a", "toggle_monitoring", "Monitor all PRs"),
|
|
238
|
+
("R", "refresh", "Refresh"),
|
|
239
|
+
("space", "primary_action", "Next action"),
|
|
240
|
+
("enter", "open_detail", "Detail"),
|
|
241
|
+
("D", "view_diff", "Pinned diff"),
|
|
242
|
+
("L", "view_run_log", "Run log"),
|
|
243
|
+
("C", "cleanup_session", "Retry cleanup"),
|
|
244
|
+
("s", "sort", "Sort"),
|
|
245
|
+
("S", "reverse_sort", "Reverse"),
|
|
246
|
+
("f", "cycle_filter_preset", "View"),
|
|
247
|
+
("/", "filter", "Filter"),
|
|
248
|
+
("ctrl+c", "cancel_review", "Cancel review"),
|
|
249
|
+
("P", "toggle_pause", "Pause AI dispatch"),
|
|
250
|
+
("y", "copy_url", "Copy URL"),
|
|
251
|
+
("?", "help", "Help"),
|
|
252
|
+
("q", "quit", "Quit"),
|
|
253
|
+
]
|
|
254
|
+
CSS = """
|
|
255
|
+
#queue { height: 1fr; }
|
|
256
|
+
#status { height: 2; }
|
|
257
|
+
#preview-scroll { height: 4; overflow-x: hidden; }
|
|
258
|
+
#preview { width: 100%; height: auto; padding: 0 2; }
|
|
259
|
+
#primary { dock: bottom; width: 100%; }
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, path: Path | None, settings: Settings, *, offline: bool = False) -> None:
|
|
263
|
+
super().__init__()
|
|
264
|
+
self.path = path
|
|
265
|
+
self.settings = settings
|
|
266
|
+
self.offline = offline
|
|
267
|
+
self.rows: dict[str, DashboardRow] = {}
|
|
268
|
+
self.history: dict[str, CycleRecord] = {}
|
|
269
|
+
self.view = settings.ui.default_filter
|
|
270
|
+
if self.view not in {"active", "requested", "following", "triage", "posted", "history"}:
|
|
271
|
+
self.view = "active"
|
|
272
|
+
self.sort = SortSpec(column=settings.ui.sort_by, descending=settings.ui.sort_desc)
|
|
273
|
+
self.attached = False
|
|
274
|
+
self.ai_paused = False
|
|
275
|
+
self.monitor_all_prs = False
|
|
276
|
+
self.reload_lock = asyncio.Lock()
|
|
277
|
+
self.sync_lock = asyncio.Lock()
|
|
278
|
+
self.refresh_notices: set[str] = set()
|
|
279
|
+
self.review_tasks: dict[str, asyncio.Task[None]] = {}
|
|
280
|
+
self.resetting_tasks: set[str] = set()
|
|
281
|
+
self.current_reviewer: str | None = None
|
|
282
|
+
self.poller = Poller(self.sync, settings.effective_poll_interval_s)
|
|
283
|
+
self.ready = False
|
|
284
|
+
self.closing = False
|
|
285
|
+
self.preview_content = ""
|
|
286
|
+
|
|
287
|
+
def compose(self) -> ComposeResult:
|
|
288
|
+
yield Header()
|
|
289
|
+
yield Static("Local queue", id="status", markup=False)
|
|
290
|
+
yield Input(
|
|
291
|
+
placeholder="Filter by repository, number, title or author", id="filter", compact=True
|
|
292
|
+
)
|
|
293
|
+
yield DataTable[Text](cursor_type="row", id="queue")
|
|
294
|
+
with VerticalScroll(id="preview-scroll"):
|
|
295
|
+
yield Static("Select a PR", id="preview", markup=False)
|
|
296
|
+
yield Button("Select a PR", id="primary")
|
|
297
|
+
yield Footer()
|
|
298
|
+
|
|
299
|
+
async def on_mount(self) -> None:
|
|
300
|
+
for index in range(1, min(9, len(self.settings.ui.columns)) + 1):
|
|
301
|
+
self._bindings.bind(str(index), f"sort_column({index - 1})", "Sort column", show=False)
|
|
302
|
+
for action, keys in self.settings.keys.items():
|
|
303
|
+
if any(action == binding[1] for binding in self.BINDINGS if isinstance(binding, tuple)):
|
|
304
|
+
for key, bindings in list(self._bindings.key_to_bindings.items()):
|
|
305
|
+
self._bindings.key_to_bindings[key] = [
|
|
306
|
+
binding for binding in bindings if binding.action != action
|
|
307
|
+
]
|
|
308
|
+
self._bindings.bind(",".join(keys), action, action.replace("_", " "))
|
|
309
|
+
choices, default = await reviewer_choices_configured(self.path)
|
|
310
|
+
self.current_reviewer = default if default in choices else next(iter(choices), None)
|
|
311
|
+
self.status(
|
|
312
|
+
f"Current AI reviewer: {self.current_reviewer or 'not configured'} - n to change"
|
|
313
|
+
)
|
|
314
|
+
preferences = await preferences_configured(self.path)
|
|
315
|
+
self.ai_paused = preferences.get("reviews_paused") == "true"
|
|
316
|
+
self.monitor_all_prs = preferences.get("monitor_all_prs") == "true"
|
|
317
|
+
if (
|
|
318
|
+
preferences.get("sort_by") in self.settings.ui.columns
|
|
319
|
+
or preferences.get("sort_by") == "attention"
|
|
320
|
+
):
|
|
321
|
+
self.sort = SortSpec(
|
|
322
|
+
column=preferences["sort_by"], descending=preferences.get("sort_desc") == "true"
|
|
323
|
+
)
|
|
324
|
+
self.query_one(DataTable[Text]).focus()
|
|
325
|
+
self.ready = True
|
|
326
|
+
await self.reload()
|
|
327
|
+
self.set_interval(1, self.local_tick)
|
|
328
|
+
if not self.offline:
|
|
329
|
+
self.run_worker(self.poller.run(), name="forge polling", exit_on_error=False)
|
|
330
|
+
if self.settings.ui.default_filter == "all":
|
|
331
|
+
self.action_browse_prs()
|
|
332
|
+
|
|
333
|
+
def selected_key(self) -> str | None:
|
|
334
|
+
if len(self.screen_stack) > 1:
|
|
335
|
+
return None
|
|
336
|
+
table = self.query_one(DataTable[Text])
|
|
337
|
+
if not table.row_count:
|
|
338
|
+
return None
|
|
339
|
+
return str(table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value)
|
|
340
|
+
|
|
341
|
+
def selected(self) -> DashboardRow | None:
|
|
342
|
+
return self.rows.get(self.selected_key() or "")
|
|
343
|
+
|
|
344
|
+
def status(self, message: str) -> None:
|
|
345
|
+
if not self.attached and len(self.screen_stack) == 1:
|
|
346
|
+
self.query_one("#status", Static).update(safe(message))
|
|
347
|
+
|
|
348
|
+
def refresh_status_once(self, message: str) -> None:
|
|
349
|
+
if (
|
|
350
|
+
message not in self.refresh_notices
|
|
351
|
+
and not self.attached
|
|
352
|
+
and len(self.screen_stack) == 1
|
|
353
|
+
):
|
|
354
|
+
self.status(message)
|
|
355
|
+
self.refresh_notices.add(message)
|
|
356
|
+
|
|
357
|
+
async def local_tick(self) -> None:
|
|
358
|
+
if self.ready and not self.closing and not self.attached and not self.reload_lock.locked():
|
|
359
|
+
# Timer callbacks share the keyboard message pump. Database reads
|
|
360
|
+
# must not hold it while reviews are starting or changing state.
|
|
361
|
+
self.run_worker(self.reload(), group="queue-refresh", exit_on_error=False)
|
|
362
|
+
|
|
363
|
+
def on_resize(self, _: Resize) -> None:
|
|
364
|
+
if self.ready:
|
|
365
|
+
self.call_after_refresh(self.local_tick)
|
|
366
|
+
|
|
367
|
+
async def reload(self) -> None:
|
|
368
|
+
if self.attached or self.closing or len(self.screen_stack) > 1:
|
|
369
|
+
return
|
|
370
|
+
async with self.reload_lock:
|
|
371
|
+
text = self.query_one(Input).value
|
|
372
|
+
if self.view == "history":
|
|
373
|
+
cycles = await history_configured(self.path, HistoryFilter())
|
|
374
|
+
self.history = {
|
|
375
|
+
str(cycle.task_id): cycle
|
|
376
|
+
for cycle in cycles
|
|
377
|
+
if text.lower()
|
|
378
|
+
in (
|
|
379
|
+
f"{cycle.pull_request.title} {cycle.pull_request.ref.namespace}/"
|
|
380
|
+
f"{cycle.pull_request.ref.repository}#{cycle.pull_request.ref.number} "
|
|
381
|
+
f"{cycle.completion_reason}"
|
|
382
|
+
).lower()
|
|
383
|
+
}
|
|
384
|
+
data = [
|
|
385
|
+
(
|
|
386
|
+
key,
|
|
387
|
+
(
|
|
388
|
+
cycle.pull_request.title,
|
|
389
|
+
f"Cycle {cycle.cycle}",
|
|
390
|
+
cycle.completion_reason or "",
|
|
391
|
+
cycle.cleanup_status,
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
for key, cycle in self.history.items()
|
|
395
|
+
]
|
|
396
|
+
columns = ["PR", "Cycle", "Completion", "Cleanup"]
|
|
397
|
+
column_ids = ["title", "cycle", "completion", "cleanup"]
|
|
398
|
+
else:
|
|
399
|
+
rows = sort_rows(await dashboard_rows(self.path, self.view, text), self.sort)
|
|
400
|
+
self.rows = {str(row.row.tracked.task_id): row for row in rows}
|
|
401
|
+
data = [
|
|
402
|
+
(key, tuple(self.cell(row, column) for column in self.settings.ui.columns))
|
|
403
|
+
for key, row in self.rows.items()
|
|
404
|
+
]
|
|
405
|
+
columns = [
|
|
406
|
+
" ".join(
|
|
407
|
+
{"number": "PR", "ai": "AI", "ci": "CI", "url": "URL"}.get(
|
|
408
|
+
word, word.title()
|
|
409
|
+
)
|
|
410
|
+
for word in column.split("_")
|
|
411
|
+
)
|
|
412
|
+
+ (
|
|
413
|
+
(" ▼" if self.sort.descending else " ▲")
|
|
414
|
+
if column == self.sort.column
|
|
415
|
+
else ""
|
|
416
|
+
)
|
|
417
|
+
for column in self.settings.ui.columns
|
|
418
|
+
]
|
|
419
|
+
column_ids = self.settings.ui.columns
|
|
420
|
+
if self.attached:
|
|
421
|
+
return
|
|
422
|
+
table = self.query_one(DataTable[Text])
|
|
423
|
+
wide = self.size.width >= 80
|
|
424
|
+
table.styles.overflow_x = "hidden" if wide else "auto"
|
|
425
|
+
table.styles.overflow_y = "hidden" if wide else "auto"
|
|
426
|
+
widths = queue_widths(column_ids, columns, data, self.size.width)
|
|
427
|
+
selected = self.selected_key()
|
|
428
|
+
old_cursor = table.cursor_row
|
|
429
|
+
# Update cells in place while identity/order is unchanged.
|
|
430
|
+
keys = [key for key, _ in data]
|
|
431
|
+
existing = [
|
|
432
|
+
str(table.coordinate_to_cell_key(Coordinate(index, 0)).row_key.value)
|
|
433
|
+
for index in range(table.row_count)
|
|
434
|
+
]
|
|
435
|
+
if (
|
|
436
|
+
keys != existing
|
|
437
|
+
or [str(column.label) for column in table.columns.values()] != columns
|
|
438
|
+
or [column.width for column in table.columns.values()] != widths
|
|
439
|
+
):
|
|
440
|
+
table.clear(columns=True)
|
|
441
|
+
for label, width in zip(columns, widths, strict=True):
|
|
442
|
+
table.add_column(label, width=width)
|
|
443
|
+
for key, cells in data:
|
|
444
|
+
table.add_row(
|
|
445
|
+
*(
|
|
446
|
+
queue_cell(value, column, width)
|
|
447
|
+
for value, column, width in zip(cells, column_ids, widths, strict=True)
|
|
448
|
+
),
|
|
449
|
+
key=key,
|
|
450
|
+
)
|
|
451
|
+
table.move_cursor(
|
|
452
|
+
row=keys.index(selected)
|
|
453
|
+
if selected in keys
|
|
454
|
+
else min(old_cursor, max(0, len(keys) - 1))
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
for index, (_, cells) in enumerate(data):
|
|
458
|
+
for col, value in enumerate(cells):
|
|
459
|
+
content = queue_cell(value, column_ids[col], widths[col])
|
|
460
|
+
if table.get_cell_at(Coordinate(index, col)) != content:
|
|
461
|
+
table.update_cell_at(Coordinate(index, col), content)
|
|
462
|
+
mode = "offline" if self.offline else "paused" if self.poller.paused else "live"
|
|
463
|
+
scope = "all open PRs" if self.monitor_all_prs else "review requests"
|
|
464
|
+
self.sub_title = f"{self.view} · {len(keys)} PRs · {mode} · Monitoring: {scope}" + (
|
|
465
|
+
" · AI paused" if self.ai_paused else ""
|
|
466
|
+
)
|
|
467
|
+
await self.preview()
|
|
468
|
+
|
|
469
|
+
def cell(self, row: DashboardRow, column: str) -> str:
|
|
470
|
+
value = column_value(row, column)
|
|
471
|
+
if column == "age" and isinstance(value, (int, float)):
|
|
472
|
+
return f"{value / 3600:.0f}h"
|
|
473
|
+
if column == "max_severity":
|
|
474
|
+
return row.max_severity or "—"
|
|
475
|
+
if column == "state" and row.row.tracked.refresh_pending:
|
|
476
|
+
return safe(value) + " -"
|
|
477
|
+
return safe(value) if value is not None else "—"
|
|
478
|
+
|
|
479
|
+
async def preview(self) -> None:
|
|
480
|
+
key = self.selected_key()
|
|
481
|
+
if key is None:
|
|
482
|
+
self.preview_content = ""
|
|
483
|
+
self.query_one("#preview", Static).update("No PRs in this view.")
|
|
484
|
+
self.query_one(Button).label = "Select a PR"
|
|
485
|
+
return
|
|
486
|
+
row = self.selected() if self.view != "history" else None
|
|
487
|
+
label = (
|
|
488
|
+
"Open historical PR"
|
|
489
|
+
if self.view == "history"
|
|
490
|
+
else {
|
|
491
|
+
"PENDING_AI_REVIEW": "Start AI review",
|
|
492
|
+
"FINDINGS_TRIAGE": "Discuss findings",
|
|
493
|
+
"QUEUED": "Review queued · Ctrl+C to cancel",
|
|
494
|
+
"AI_RUNNING": "Review running · Ctrl+C to cancel",
|
|
495
|
+
}.get(row.row.tracked.state if row else "", "Open PR for human review")
|
|
496
|
+
)
|
|
497
|
+
self.query_one(Button).label = label
|
|
498
|
+
if self.selected_key() != key or self.attached:
|
|
499
|
+
return
|
|
500
|
+
if not self.settings.ui.preview_pane:
|
|
501
|
+
return
|
|
502
|
+
detail = await detail_configured(self.path, UUID(key))
|
|
503
|
+
if self.selected_key() == key and not self.attached:
|
|
504
|
+
notice = row.row.tracked.notice + "\n" if row else ""
|
|
505
|
+
content = notice + (
|
|
506
|
+
describe(detail) if self.view == "history" else preview_summary(detail)
|
|
507
|
+
)
|
|
508
|
+
if content != self.preview_content:
|
|
509
|
+
self.query_one("#preview", Static).update(
|
|
510
|
+
Text(content, no_wrap=False, overflow="fold")
|
|
511
|
+
)
|
|
512
|
+
self.preview_content = content
|
|
513
|
+
|
|
514
|
+
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
515
|
+
if len(self.screen_stack) == 1:
|
|
516
|
+
event.stop()
|
|
517
|
+
await self.action_open_detail()
|
|
518
|
+
|
|
519
|
+
async def on_data_table_row_highlighted(self, _: DataTable.RowHighlighted) -> None:
|
|
520
|
+
if not self.reload_lock.locked() and not self.attached and len(self.screen_stack) == 1:
|
|
521
|
+
self.run_worker(
|
|
522
|
+
self.preview(), group="selection-preview", exclusive=True, exit_on_error=False
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def on_input_changed(self, _: Input.Changed) -> None:
|
|
526
|
+
if self.ready and len(self.screen_stack) == 1:
|
|
527
|
+
await self.reload()
|
|
528
|
+
|
|
529
|
+
def on_input_submitted(self, _: Input.Submitted) -> None:
|
|
530
|
+
self.query_one(DataTable[Text]).focus()
|
|
531
|
+
|
|
532
|
+
def action_filter(self) -> None:
|
|
533
|
+
self.query_one(Input).focus()
|
|
534
|
+
|
|
535
|
+
async def on_button_pressed(self, _: Button.Pressed) -> None:
|
|
536
|
+
self.action_primary_action()
|
|
537
|
+
|
|
538
|
+
def start(self, operation: Awaitable[None]) -> None:
|
|
539
|
+
self.run_worker(self.guarded(operation), exit_on_error=False)
|
|
540
|
+
|
|
541
|
+
async def guarded(self, operation: Awaitable[None]) -> None:
|
|
542
|
+
try:
|
|
543
|
+
await operation
|
|
544
|
+
except (ForgeError, ConfigError, SourceError, ReviewConflict, HostBootError) as exc:
|
|
545
|
+
logging.getLogger(__name__).exception("Dashboard operation failed")
|
|
546
|
+
self.status(f"Operation failed: {exc}")
|
|
547
|
+
except Exception as exc:
|
|
548
|
+
logging.getLogger(__name__).exception("Dashboard operation failed")
|
|
549
|
+
self.status(f"Operation failed ({type(exc).__name__}); see diagnostic log for details.")
|
|
550
|
+
finally:
|
|
551
|
+
if not self.attached:
|
|
552
|
+
await self.reload()
|
|
553
|
+
|
|
554
|
+
async def sync(self) -> None:
|
|
555
|
+
if self.sync_lock.locked():
|
|
556
|
+
return
|
|
557
|
+
async with self.sync_lock:
|
|
558
|
+
try:
|
|
559
|
+
self.refresh_status_once(
|
|
560
|
+
"Refreshing GitHub; requests are paced to respect rate limits."
|
|
561
|
+
)
|
|
562
|
+
logging.getLogger(__name__).debug("Forge sync started")
|
|
563
|
+
observations = await self.poller.refresh_observed(
|
|
564
|
+
lambda: poll_configured(self.path)
|
|
565
|
+
)
|
|
566
|
+
quota = observations.minimum_core
|
|
567
|
+
logging.getLogger(__name__).info(
|
|
568
|
+
"Forge sync completed limit=%s remaining=%s",
|
|
569
|
+
quota.limit if quota else None,
|
|
570
|
+
quota.remaining if quota else None,
|
|
571
|
+
)
|
|
572
|
+
self.refresh_status_once("Forge refreshed")
|
|
573
|
+
except ForgeError as exc:
|
|
574
|
+
logging.getLogger(__name__).exception("Forge sync failed")
|
|
575
|
+
self.status(f"Refresh pending: {exc}. Previous evidence retained.")
|
|
576
|
+
raise
|
|
577
|
+
except Exception:
|
|
578
|
+
logging.getLogger(__name__).exception("Forge sync failed")
|
|
579
|
+
self.status("Refresh pending; previous evidence retained. Polling will retry.")
|
|
580
|
+
raise
|
|
581
|
+
finally:
|
|
582
|
+
if not self.attached:
|
|
583
|
+
await self.reload()
|
|
584
|
+
|
|
585
|
+
def action_refresh(self) -> None:
|
|
586
|
+
self.start(self.sync())
|
|
587
|
+
|
|
588
|
+
async def open_selected(self) -> None:
|
|
589
|
+
key = self.selected_key()
|
|
590
|
+
if self.view == "history":
|
|
591
|
+
cycle = self.history.get(key or "")
|
|
592
|
+
if cycle is None:
|
|
593
|
+
return
|
|
594
|
+
url = cycle.pull_request.url
|
|
595
|
+
else:
|
|
596
|
+
row = self.selected()
|
|
597
|
+
if row is None:
|
|
598
|
+
return
|
|
599
|
+
url = await state_configured(
|
|
600
|
+
self.path, row.row.tracked.pull_request.url, row.row.account.login, waiting=False
|
|
601
|
+
)
|
|
602
|
+
try:
|
|
603
|
+
opened = await open_browser(url, self.settings.ui.browser_command)
|
|
604
|
+
except OSError, TimeoutError:
|
|
605
|
+
opened = False
|
|
606
|
+
if not opened:
|
|
607
|
+
self.copy_to_clipboard(url)
|
|
608
|
+
self.status(f"Open this URL (copied if supported): {url}")
|
|
609
|
+
if self.view != "history":
|
|
610
|
+
table = self.query_one(DataTable[Text])
|
|
611
|
+
table.move_cursor(row=min(table.cursor_row + 1, table.row_count - 1))
|
|
612
|
+
|
|
613
|
+
def action_open_in_browser(self) -> None:
|
|
614
|
+
self.start(self.open_selected())
|
|
615
|
+
|
|
616
|
+
def action_copy_url(self) -> None:
|
|
617
|
+
key = self.selected_key()
|
|
618
|
+
cycle = self.history.get(key or "") if self.view == "history" else None
|
|
619
|
+
row = self.selected()
|
|
620
|
+
url = cycle.pull_request.url if cycle else row.row.tracked.pull_request.url if row else ""
|
|
621
|
+
if url:
|
|
622
|
+
self.copy_to_clipboard(url)
|
|
623
|
+
self.status(url)
|
|
624
|
+
|
|
625
|
+
async def state_action(self, *, approve: bool = False) -> None:
|
|
626
|
+
row = self.selected()
|
|
627
|
+
if row is None or self.view == "history":
|
|
628
|
+
return
|
|
629
|
+
pr = row.row.tracked.pull_request
|
|
630
|
+
if approve:
|
|
631
|
+
status = await approved_configured(self.path, pr.url, row.row.account.login)
|
|
632
|
+
self.status(f"Completion recorded locally · cleanup: {status}")
|
|
633
|
+
else:
|
|
634
|
+
await state_configured(self.path, pr.url, row.row.account.login, waiting=True)
|
|
635
|
+
|
|
636
|
+
def action_mark_waiting_for_author(self) -> None:
|
|
637
|
+
self.start(self.state_action())
|
|
638
|
+
|
|
639
|
+
def action_mark_approved(self) -> None:
|
|
640
|
+
self.start(self.state_action(approve=True))
|
|
641
|
+
|
|
642
|
+
async def action_next_reviewer(self) -> None:
|
|
643
|
+
await self.guarded(self.next_reviewer())
|
|
644
|
+
|
|
645
|
+
async def next_reviewer(self) -> None:
|
|
646
|
+
choices, _ = await reviewer_choices_configured(self.path)
|
|
647
|
+
if not choices:
|
|
648
|
+
self.status("No native reviewers configured; add a Codex or Claude reviewer in config.")
|
|
649
|
+
return
|
|
650
|
+
current = self.current_reviewer
|
|
651
|
+
index = choices.index(current) + 1 if current in choices else 0
|
|
652
|
+
reviewer = choices[index % len(choices)]
|
|
653
|
+
self.current_reviewer = reviewer
|
|
654
|
+
if len(choices) == 1:
|
|
655
|
+
self.status(
|
|
656
|
+
f"Only {reviewer} is configured; add another native reviewer in config to switch."
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
self.status(f"Current AI reviewer: {reviewer}. Press r to start a review.")
|
|
660
|
+
|
|
661
|
+
def action_run_review(self) -> None:
|
|
662
|
+
row = self.selected()
|
|
663
|
+
key = self.selected_key()
|
|
664
|
+
if (
|
|
665
|
+
row is None
|
|
666
|
+
or key is None
|
|
667
|
+
or self.view == "history"
|
|
668
|
+
or key in self.review_tasks
|
|
669
|
+
or key in self.resetting_tasks
|
|
670
|
+
):
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
reviewer = self.current_reviewer
|
|
674
|
+
pr_number = row.row.tracked.pull_request.ref.number
|
|
675
|
+
|
|
676
|
+
async def review() -> None:
|
|
677
|
+
logging.getLogger(__name__).info("Review requested task=%s pr=%s", key, pr_number)
|
|
678
|
+
try:
|
|
679
|
+
result = await review_configured(
|
|
680
|
+
self.path,
|
|
681
|
+
row.row.tracked.pull_request.url,
|
|
682
|
+
row.row.account.login,
|
|
683
|
+
reviewer,
|
|
684
|
+
self.status,
|
|
685
|
+
)
|
|
686
|
+
logging.getLogger(__name__).info(
|
|
687
|
+
"Review finished task=%s pr=%s status=%s", key, pr_number, result.status
|
|
688
|
+
)
|
|
689
|
+
if result.status_reason:
|
|
690
|
+
self.status(
|
|
691
|
+
f"AI review {result.status}: {result.status_reason} Press L for output."
|
|
692
|
+
)
|
|
693
|
+
else:
|
|
694
|
+
self.status(f"AI review {result.status}; no publication or approval implied.")
|
|
695
|
+
finally:
|
|
696
|
+
self.review_tasks.pop(key, None)
|
|
697
|
+
|
|
698
|
+
self.review_tasks[key] = asyncio.create_task(self.guarded(review()))
|
|
699
|
+
|
|
700
|
+
async def action_cancel_review(self) -> None:
|
|
701
|
+
key = self.selected_key()
|
|
702
|
+
if key is None or self.view == "history":
|
|
703
|
+
return
|
|
704
|
+
await cancel_task_configured(self.path, UUID(key))
|
|
705
|
+
task = self.review_tasks.get(key)
|
|
706
|
+
if task:
|
|
707
|
+
task.cancel()
|
|
708
|
+
await self.reload()
|
|
709
|
+
|
|
710
|
+
def action_reset_pr(self) -> None:
|
|
711
|
+
key = self.selected_key()
|
|
712
|
+
if key is None or self.view == "history" or self.attached or key in self.resetting_tasks:
|
|
713
|
+
return
|
|
714
|
+
self.resetting_tasks.add(key)
|
|
715
|
+
|
|
716
|
+
async def stop_review() -> None:
|
|
717
|
+
task = self.review_tasks.get(key)
|
|
718
|
+
if task is not None:
|
|
719
|
+
task.cancel()
|
|
720
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
721
|
+
|
|
722
|
+
async def reset() -> None:
|
|
723
|
+
try:
|
|
724
|
+
# Finish any in-flight refresh before removing its saved tracking.
|
|
725
|
+
async with self.sync_lock:
|
|
726
|
+
self.status("Resetting PR and stopping its review…")
|
|
727
|
+
result = await reset_task_configured(self.path, UUID(key), stop_review)
|
|
728
|
+
self.status(
|
|
729
|
+
f"PR reset; next forge refresh may rediscover it. Cleanup: {result}"
|
|
730
|
+
)
|
|
731
|
+
finally:
|
|
732
|
+
self.resetting_tasks.discard(key)
|
|
733
|
+
|
|
734
|
+
self.start(reset())
|
|
735
|
+
|
|
736
|
+
async def attach(self, row: DashboardRow) -> None:
|
|
737
|
+
if self.attached:
|
|
738
|
+
return
|
|
739
|
+
logging.getLogger(__name__).info(
|
|
740
|
+
"Foreground agent requested pr=%s", row.row.tracked.pull_request.ref.number
|
|
741
|
+
)
|
|
742
|
+
self.attached = True
|
|
743
|
+
error: BaseException | None = None
|
|
744
|
+
code: int | None = None
|
|
745
|
+
try:
|
|
746
|
+
async with handoff_configured(
|
|
747
|
+
self.path, row.row.tracked.pull_request.url, row.row.account.login
|
|
748
|
+
) as (launch, started):
|
|
749
|
+
with self.suspend():
|
|
750
|
+
try:
|
|
751
|
+
code = await run_foreground(
|
|
752
|
+
launch.argv,
|
|
753
|
+
cwd=launch.cwd,
|
|
754
|
+
env=foreground_environment(launch),
|
|
755
|
+
started=started,
|
|
756
|
+
)
|
|
757
|
+
except BaseException as exc:
|
|
758
|
+
error = exc
|
|
759
|
+
# Textual's suspend must exit normally to restore the dashboard.
|
|
760
|
+
if error is not None:
|
|
761
|
+
raise error
|
|
762
|
+
finally:
|
|
763
|
+
self.attached = False
|
|
764
|
+
logging.getLogger(__name__).info("Foreground agent exited code=%s", code)
|
|
765
|
+
if code != 0:
|
|
766
|
+
self.status(
|
|
767
|
+
f"Agent exited {code}; forge refresh attempted. Check refresh-pending badges."
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
def action_resume_session(self) -> None:
|
|
771
|
+
row = self.selected()
|
|
772
|
+
if row and self.view != "history" and not self.attached:
|
|
773
|
+
self.start(self.attach(row))
|
|
774
|
+
|
|
775
|
+
def action_primary_action(self) -> None:
|
|
776
|
+
row = self.selected()
|
|
777
|
+
if self.view == "history":
|
|
778
|
+
self.action_open_in_browser()
|
|
779
|
+
elif row:
|
|
780
|
+
state = row.row.tracked.state
|
|
781
|
+
if state == "PENDING_AI_REVIEW":
|
|
782
|
+
self.action_run_review()
|
|
783
|
+
elif state == "FINDINGS_TRIAGE":
|
|
784
|
+
self.action_resume_session()
|
|
785
|
+
elif state in {"QUEUED", "AI_RUNNING"}:
|
|
786
|
+
self.status("Review in progress; Ctrl+C cancels the selected local run.")
|
|
787
|
+
else:
|
|
788
|
+
self.action_open_in_browser()
|
|
789
|
+
|
|
790
|
+
async def action_open_detail(self) -> None:
|
|
791
|
+
key = self.selected_key()
|
|
792
|
+
if key:
|
|
793
|
+
self.push_screen(DetailScreen(describe(await detail_configured(self.path, UUID(key)))))
|
|
794
|
+
|
|
795
|
+
async def action_view_diff(self) -> None:
|
|
796
|
+
key = self.selected_key()
|
|
797
|
+
if key:
|
|
798
|
+
|
|
799
|
+
async def show() -> None:
|
|
800
|
+
content = await diff_configured(self.path, UUID(key))
|
|
801
|
+
self.push_screen(DetailScreen(safe(content)))
|
|
802
|
+
|
|
803
|
+
self.start(show())
|
|
804
|
+
|
|
805
|
+
async def action_view_run_log(self) -> None:
|
|
806
|
+
key = self.selected_key()
|
|
807
|
+
if key:
|
|
808
|
+
self.push_screen(DetailScreen(safe(await log_configured(self.path, UUID(key)))))
|
|
809
|
+
|
|
810
|
+
def action_browse_prs(self) -> None:
|
|
811
|
+
async def followed(task_id: str | None) -> None:
|
|
812
|
+
if task_id is not None:
|
|
813
|
+
self.view = "active"
|
|
814
|
+
self.query_one(Input).value = ""
|
|
815
|
+
await self.reload()
|
|
816
|
+
keys = list(self.rows)
|
|
817
|
+
if task_id in keys:
|
|
818
|
+
self.query_one(DataTable[Text]).move_cursor(row=keys.index(task_id))
|
|
819
|
+
self.status("PR followed; press r to explicitly start AI review.")
|
|
820
|
+
|
|
821
|
+
self.push_screen(BrowseScreen(self.path, len(self.settings.forges)), followed)
|
|
822
|
+
|
|
823
|
+
def action_toggle_monitoring(self) -> None:
|
|
824
|
+
async def toggle() -> None:
|
|
825
|
+
try:
|
|
826
|
+
enabled = await toggle_monitoring_configured(self.path)
|
|
827
|
+
except ForgeError as exc:
|
|
828
|
+
self.status(str(exc))
|
|
829
|
+
return
|
|
830
|
+
self.monitor_all_prs = enabled
|
|
831
|
+
self.view = "active"
|
|
832
|
+
self.query_one(Input).value = ""
|
|
833
|
+
if enabled and not self.offline:
|
|
834
|
+
await self.sync()
|
|
835
|
+
self.status(
|
|
836
|
+
"Monitoring all open PRs in configured repositories."
|
|
837
|
+
if enabled
|
|
838
|
+
else "Monitoring review requests and followed PRs."
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
self.start(toggle())
|
|
842
|
+
|
|
843
|
+
async def action_view_history(self) -> None:
|
|
844
|
+
self.view = "active" if self.view == "history" else "history"
|
|
845
|
+
await self.reload()
|
|
846
|
+
|
|
847
|
+
async def action_cycle_filter_preset(self) -> None:
|
|
848
|
+
views = ["active", "requested", "following", "triage", "posted", "history"]
|
|
849
|
+
self.view = views[(views.index(self.view) + 1) % len(views)]
|
|
850
|
+
await self.reload()
|
|
851
|
+
|
|
852
|
+
async def action_cleanup_session(self) -> None:
|
|
853
|
+
self.status(f"Cleanup: {await cleanup_configured(self.path)}")
|
|
854
|
+
await self.reload()
|
|
855
|
+
|
|
856
|
+
async def save_sort(self) -> None:
|
|
857
|
+
await save_sort_configured(self.path, self.sort)
|
|
858
|
+
await self.reload()
|
|
859
|
+
|
|
860
|
+
async def action_sort(self) -> None:
|
|
861
|
+
columns = self.settings.ui.columns
|
|
862
|
+
index = columns.index(self.sort.column) if self.sort.column in columns else -1
|
|
863
|
+
self.sort = SortSpec(
|
|
864
|
+
column=columns[(index + 1) % len(columns)], descending=self.sort.descending
|
|
865
|
+
)
|
|
866
|
+
await self.save_sort()
|
|
867
|
+
|
|
868
|
+
async def action_reverse_sort(self) -> None:
|
|
869
|
+
self.sort = self.sort.model_copy(update={"descending": not self.sort.descending})
|
|
870
|
+
await self.save_sort()
|
|
871
|
+
|
|
872
|
+
async def action_sort_column(self, index: int) -> None:
|
|
873
|
+
self.sort = SortSpec(
|
|
874
|
+
column=self.settings.ui.columns[index], descending=self.sort.descending
|
|
875
|
+
)
|
|
876
|
+
await self.save_sort()
|
|
877
|
+
|
|
878
|
+
async def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
|
|
879
|
+
if self.view != "history":
|
|
880
|
+
await self.action_sort_column(event.column_index)
|
|
881
|
+
|
|
882
|
+
async def action_toggle_pause(self) -> None:
|
|
883
|
+
self.ai_paused = not self.ai_paused
|
|
884
|
+
await pause_configured(self.path, self.ai_paused)
|
|
885
|
+
self.status(
|
|
886
|
+
"AI dispatch paused; current runs finish" if self.ai_paused else "AI dispatch resumed"
|
|
887
|
+
)
|
|
888
|
+
await self.reload()
|
|
889
|
+
|
|
890
|
+
def action_help(self) -> None:
|
|
891
|
+
self.push_screen(
|
|
892
|
+
DetailScreen(
|
|
893
|
+
"r starts an AI run only on your explicit action. c resumes its native session.\n"
|
|
894
|
+
"n chooses the next configured reviewer for this PR; r starts that reviewer.\n"
|
|
895
|
+
"Tell the agent explicitly which feedback to post.\n"
|
|
896
|
+
"Opening the session grants no posting permission.\n"
|
|
897
|
+
"o chooses manual review and opens the browser. w chooses waiting for author.\n"
|
|
898
|
+
"m records your completed approval locally and schedules cleanup.\n"
|
|
899
|
+
"It does not submit approval.\n"
|
|
900
|
+
"a toggles monitoring all open PRs in configured repositories.\n"
|
|
901
|
+
"h browses offline history. C retries due cleanup. R refreshes forge evidence.\n"
|
|
902
|
+
"s cycles sort columns; S reverses; 1-9 or a column header selects a sort.\n"
|
|
903
|
+
"/ filters; f cycles views. Ctrl+C cancels the selected run launched here.\n"
|
|
904
|
+
"P pauses AI dispatch; forge polling continues.\n"
|
|
905
|
+
"q quits and cancels reviews launched in this dashboard."
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
async def on_unmount(self) -> None:
|
|
910
|
+
self.closing = True
|
|
911
|
+
# Textual cancels workers on exit but does not await their finalizers.
|
|
912
|
+
# Keep the event loop alive for polling/action database cleanup as well
|
|
913
|
+
# as review jobs; do not cancel a worker already finishing cancellation.
|
|
914
|
+
workers = list(self.workers)
|
|
915
|
+
for worker in workers:
|
|
916
|
+
if not worker.is_cancelled:
|
|
917
|
+
worker.cancel()
|
|
918
|
+
await asyncio.gather(*(worker.wait() for worker in workers), return_exceptions=True)
|
|
919
|
+
for key, task in list(self.review_tasks.items()):
|
|
920
|
+
await cancel_task_configured(self.path, UUID(key))
|
|
921
|
+
task.cancel()
|
|
922
|
+
await asyncio.gather(*self.review_tasks.values(), return_exceptions=True)
|
|
923
|
+
|
|
924
|
+
async def action_quit(self) -> None:
|
|
925
|
+
await self.on_unmount()
|
|
926
|
+
self.exit()
|