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,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()