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,65 @@
1
+ """Remember successful Git transports without rewriting unrelated configuration."""
2
+
3
+ import fcntl
4
+ import os
5
+ import tempfile
6
+ from collections.abc import MutableMapping
7
+ from pathlib import Path
8
+ from typing import Any, Literal, cast
9
+
10
+ import tomlkit
11
+ from tomlkit.exceptions import TOMLKitError
12
+
13
+ from diffbutler.config.settings import ConfigError
14
+
15
+
16
+ def save_git_transport(
17
+ path: Path,
18
+ repository: str,
19
+ transport: Literal["https", "ssh"],
20
+ *,
21
+ expected: Literal["auto", "https", "ssh"],
22
+ ) -> None:
23
+ """Merge under a cross-process lock; preserve preferences edited during a fetch."""
24
+ selected = path.expanduser().resolve()
25
+ try:
26
+ selected.parent.mkdir(parents=True, exist_ok=True)
27
+ with selected.with_name(selected.name + ".lock").open("a") as lock:
28
+ fcntl.flock(lock, fcntl.LOCK_EX)
29
+ original = selected.read_text(encoding="utf-8") if selected.exists() else None
30
+ document = cast(MutableMapping[str, Any], tomlkit.parse(original or ""))
31
+ if "repos" not in document:
32
+ document["repos"] = tomlkit.table()
33
+ repos = document["repos"]
34
+ if not isinstance(repos, MutableMapping):
35
+ raise ConfigError("Could not save Git transport: repos must be a table")
36
+ repos = cast(MutableMapping[str, Any], repos)
37
+ if repository not in repos:
38
+ repos[repository] = tomlkit.table()
39
+ entry = repos[repository]
40
+ if not isinstance(entry, MutableMapping):
41
+ raise ConfigError("Could not save Git transport: repository must be a table")
42
+ entry = cast(MutableMapping[str, Any], entry)
43
+ current = entry.get("git_transport", "auto")
44
+ if current != expected or current == transport:
45
+ return
46
+ entry["git_transport"] = transport
47
+ temporary: Path | None = None
48
+ try:
49
+ with tempfile.NamedTemporaryFile(
50
+ mode="w", encoding="utf-8", dir=selected.parent, delete=False
51
+ ) as stream:
52
+ temporary = Path(stream.name)
53
+ stream.write(tomlkit.dumps(document))
54
+ if original is None:
55
+ os.link(temporary, selected)
56
+ else:
57
+ if selected.read_text(encoding="utf-8") != original:
58
+ raise ConfigError("Configuration changed while saving Git transport")
59
+ temporary.chmod(selected.stat().st_mode & 0o777)
60
+ os.replace(temporary, selected)
61
+ finally:
62
+ if temporary is not None:
63
+ temporary.unlink(missing_ok=True)
64
+ except OSError, UnicodeError, TOMLKitError:
65
+ raise ConfigError("Could not save the working Git transport to configuration") from None
@@ -0,0 +1 @@
1
+ """UI-independent application services."""
@@ -0,0 +1,103 @@
1
+ """Configured shared-core entry points with owned database/forge lifetimes."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from diffbutler.config.settings import Settings, load_settings
10
+ from diffbutler.core.sync import DiffButler, PullRequestRow, SyncReport, list_queue
11
+ from diffbutler.domain.forge import PRPage, PRQuery
12
+ from diffbutler.forges.base import ForgeError
13
+ from diffbutler.forges.registry import open_forge
14
+ from diffbutler.store.dashboard import SQLiteDashboardStore
15
+ from diffbutler.store.database import Database
16
+ from diffbutler.store.queue import SQLiteQueueStore
17
+
18
+
19
+ @asynccontextmanager
20
+ async def local_store(path: Path | None) -> AsyncGenerator[tuple[SQLiteQueueStore, Settings]]:
21
+ settings = await asyncio.to_thread(load_settings, path)
22
+ database = Database(settings.db.url)
23
+ try:
24
+ await database.initialize(auto_migrate=settings.db.auto_migrate)
25
+ yield SQLiteQueueStore(database), settings
26
+ finally:
27
+ await database.close()
28
+
29
+
30
+ @asynccontextmanager
31
+ async def remote_service(
32
+ store: SQLiteQueueStore,
33
+ settings: Settings,
34
+ index: int,
35
+ ) -> AsyncGenerator[DiffButler]:
36
+ if not 1 <= index <= len(settings.forges):
37
+ raise ForgeError("Select a configured forge with --forge (one-based index)")
38
+ config = settings.forges[index - 1]
39
+ forge = await open_forge(config)
40
+ try:
41
+ preferences = await SQLiteDashboardStore(store.database).preferences()
42
+ yield DiffButler(
43
+ store,
44
+ forge,
45
+ expected_login=config.login,
46
+ monitor_repositories=tuple(config.repositories)
47
+ if preferences.get("monitor_all_prs") == "true"
48
+ else (),
49
+ )
50
+ finally:
51
+ await forge.close()
52
+
53
+
54
+ async def sync_configured(path: Path | None, index: int | None = None) -> tuple[SyncReport, ...]:
55
+ async with local_store(path) as (store, settings):
56
+ indexes = [index] if index is not None else list(range(1, len(settings.forges) + 1))
57
+ if not indexes:
58
+ raise ForgeError("No forge configured; add a [[forges]] section to configuration")
59
+ reports: list[SyncReport] = []
60
+ for selected in indexes:
61
+ async with remote_service(store, settings, selected) as service:
62
+ reports.append(await service.sync())
63
+ from diffbutler.core.managed import ManagedReviews, owned_directories
64
+
65
+ managed = ManagedReviews(store.database, settings, await owned_directories(settings))
66
+ await managed.cleanup()
67
+ return tuple(reports)
68
+
69
+
70
+ async def follow_configured(path: Path | None, index: int, value: str) -> PullRequestRow:
71
+ async with (
72
+ local_store(path) as (store, settings),
73
+ remote_service(store, settings, index) as service,
74
+ ):
75
+ return await service.follow_pr(service.forge.ref(value))
76
+
77
+
78
+ async def browse_configured(
79
+ path: Path | None,
80
+ index: int,
81
+ query: PRQuery,
82
+ cursor: str | None,
83
+ ) -> PRPage:
84
+ async with (
85
+ local_store(path) as (store, settings),
86
+ remote_service(store, settings, index) as service,
87
+ ):
88
+ return await service.browse_prs(query, cursor)
89
+
90
+
91
+ async def local_queue(
92
+ path: Path | None,
93
+ view: Literal["active", "requested", "following"],
94
+ login: str | None,
95
+ ) -> tuple[PullRequestRow, ...]:
96
+ async with local_store(path) as (store, settings):
97
+ return await list_queue(
98
+ store,
99
+ view=view,
100
+ login=login,
101
+ include_own_prs=settings.ui.include_own_prs,
102
+ include_assigned_prs=settings.ui.include_assigned_prs,
103
+ )
@@ -0,0 +1,54 @@
1
+ """Internal context assembly; does not authorize or launch a reviewer process."""
2
+
3
+ import asyncio
4
+
5
+ from diffbutler.domain.forge import PullRequest
6
+ from diffbutler.domain.review import ReviewConflict, RunRecord, RunStatus
7
+ from diffbutler.forges.base import DiffIndex
8
+ from diffbutler.reviewers.base import ReviewBudget, ReviewContext, ReviewSources
9
+ from diffbutler.reviewers.redaction import RedactingSourceReader, redact_and_report
10
+
11
+
12
+ async def assemble_context(
13
+ *,
14
+ run: RunRecord,
15
+ pr: PullRequest,
16
+ sources: ReviewSources,
17
+ instructions: str,
18
+ schema_json: str,
19
+ budget: ReviewBudget,
20
+ ) -> ReviewContext:
21
+ """Assemble local mediated context for an already claimed run.
22
+
23
+ The caller must obtain the PR for this task and recheck durable ownership and
24
+ current policy before execution. This snapshot check is not a worker lease or
25
+ an isolation boundary. No source is sent to a model by this function.
26
+ """
27
+ if run.status != RunStatus.RUNNING or pr.state != "open":
28
+ raise ReviewConflict("Context requires a running attempt and an open pull request")
29
+ expected = (run.base_sha, run.head_sha)
30
+ if (pr.base_sha, pr.head_sha) != expected or (sources.base_sha, sources.head_sha) != expected:
31
+ raise ReviewConflict("Review context revisions do not match the requested analysis")
32
+ async with asyncio.timeout(budget.timeout_s):
33
+ diff = await sources.read_diff()
34
+ return ReviewContext(
35
+ pr=pr,
36
+ session_id=run.session_id,
37
+ run_id=run.id,
38
+ comparison_base_sha=sources.comparison_base_sha,
39
+ # Anchor identity records the requested PR pair; hunks and BASE reads
40
+ # both come from its resolved merge base, not the target branch tip.
41
+ diff_index=DiffIndex.parse(diff, base_sha=run.base_sha, head_sha=run.head_sha),
42
+ diff=redact_and_report(diff.encode("utf-8")).decode("utf-8"),
43
+ changed_files=sources.changed_files,
44
+ sources=RedactingSourceReader(sources),
45
+ instructions=instructions,
46
+ schema_json=schema_json,
47
+ budget=budget,
48
+ limitations=(
49
+ "Source is available through mediated reads only; no checkout is provided.",
50
+ "Project tests, builds, and application code were not executed.",
51
+ "Source credential patterns are masked heuristically; undetected secrets may remain.",
52
+ *sources.limitations,
53
+ ),
54
+ )
@@ -0,0 +1,393 @@
1
+ """Shared dashboard projections, ordering, history, and explicit state actions."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from typing import Literal, Protocol
6
+ from uuid import UUID
7
+
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from diffbutler.core.application import local_store, remote_service, sync_configured
11
+ from diffbutler.core.managed import resolve_local
12
+ from diffbutler.core.polling import Poller as Poller
13
+ from diffbutler.core.review import ReviewResult, ReviewService
14
+ from diffbutler.core.sync import PullRequestRow, list_queue
15
+ from diffbutler.domain.dashboard import COLUMN_IDS, STATE_LABELS, CycleRecord
16
+ from diffbutler.domain.review import ReviewConflict, ReviewPolicy
17
+ from diffbutler.forges.base import ForgeError
18
+ from diffbutler.store.base import ReviewStore
19
+ from diffbutler.store.dashboard import SQLiteDashboardStore
20
+ from diffbutler.store.review import SQLiteReviewStore
21
+
22
+
23
+ class DashboardStore(Protocol):
24
+ async def cycles(self) -> tuple[CycleRecord, ...]: ...
25
+ async def choose_state(self, task_id: UUID, *, waiting: bool) -> None: ...
26
+
27
+
28
+ class HistoryFilter(BaseModel):
29
+ repository: str | None = None
30
+ number: int | None = None
31
+ reason: str | None = None
32
+ login: str | None = None
33
+ since: datetime | None = None
34
+ until: datetime | None = None
35
+ completed: bool | None = True
36
+
37
+
38
+ class CycleDetail(BaseModel):
39
+ model_config = ConfigDict(frozen=True)
40
+ cycle: CycleRecord
41
+ results: tuple[ReviewResult, ...]
42
+
43
+
44
+ class DashboardRow(BaseModel):
45
+ model_config = ConfigDict(frozen=True)
46
+ row: PullRequestRow
47
+ run_status: str = ""
48
+ reviewed_sha: str = ""
49
+ findings: int | None = None
50
+ max_severity: str = ""
51
+ unverified: int = 0
52
+ session_name: str = ""
53
+ attention: float = 0
54
+
55
+
56
+ class SortSpec(BaseModel):
57
+ column: str = "attention"
58
+ descending: bool = True
59
+
60
+
61
+ class DashboardService:
62
+ def __init__(self, store: DashboardStore, reviews: ReviewStore) -> None:
63
+ self.store = store
64
+ self.reviews = ReviewService(reviews, ReviewPolicy(frozenset()))
65
+
66
+ async def history(self, filters: HistoryFilter) -> tuple[CycleRecord, ...]:
67
+ def matches(cycle: CycleRecord) -> bool:
68
+ pr = cycle.pull_request
69
+ return (
70
+ (filters.completed is None or (cycle.completed_at is not None) == filters.completed)
71
+ and (
72
+ filters.repository is None
73
+ or filters.repository == f"{pr.ref.namespace}/{pr.ref.repository}"
74
+ )
75
+ and (filters.number is None or filters.number == pr.ref.number)
76
+ and (filters.reason is None or filters.reason == cycle.completion_reason)
77
+ and (filters.login is None or filters.login.lower() == cycle.account.login.lower())
78
+ and (
79
+ filters.since is None
80
+ or (cycle.completed_at is not None and cycle.completed_at >= filters.since)
81
+ )
82
+ and (
83
+ filters.until is None
84
+ or (cycle.completed_at is not None and cycle.completed_at <= filters.until)
85
+ )
86
+ )
87
+
88
+ return tuple(cycle for cycle in await self.store.cycles() if matches(cycle))
89
+
90
+ async def detail(self, task_id: UUID) -> CycleDetail:
91
+ cycle = next((item for item in await self.store.cycles() if item.task_id == task_id), None)
92
+ if cycle is None:
93
+ raise ReviewConflict("History cycle does not exist")
94
+ results: list[ReviewResult] = []
95
+ for run in cycle.runs:
96
+ result = await self.reviews.get_result(run.id)
97
+ if result is not None:
98
+ # Normal history never loads raw terminal output into a UI.
99
+ results.append(result.model_copy(update={"stdout": None, "stderr": None}))
100
+ return CycleDetail(cycle=cycle, results=tuple(results))
101
+
102
+ async def continue_manual_review(self, task_id: UUID) -> None:
103
+ await self.store.choose_state(task_id, waiting=False)
104
+
105
+ async def mark_waiting_for_author(self, task_id: UUID) -> None:
106
+ await self.store.choose_state(task_id, waiting=True)
107
+
108
+
109
+ SEVERITIES = {"question": 0, "nit": 1, "minor": 2, "important": 3, "blocking": 4}
110
+
111
+
112
+ def column_value(row: DashboardRow, column: str) -> str | int | float | None:
113
+ tracked = row.row.tracked
114
+ pr = tracked.pull_request
115
+ if column == "attention":
116
+ return row.attention
117
+ values: dict[str, str | int | float | None] = {
118
+ "state": STATE_LABELS.get(tracked.state, tracked.state),
119
+ "repo": f"{pr.ref.namespace}/{pr.ref.repository}",
120
+ "number": pr.ref.number,
121
+ "title": pr.title,
122
+ "author": pr.author,
123
+ "ai_status": row.run_status,
124
+ "findings": row.findings,
125
+ "max_severity": SEVERITIES.get(row.max_severity, -1),
126
+ "age": (datetime.now(UTC) - pr.created_at).total_seconds(),
127
+ "updated": pr.updated_at.timestamp(),
128
+ "branch": pr.head_ref,
129
+ "draft": int(pr.is_draft),
130
+ "reviewers": ", ".join((*pr.requested_users, *pr.requested_teams)),
131
+ "unverified": row.unverified,
132
+ "url": pr.url,
133
+ "additions": pr.additions,
134
+ "deletions": pr.deletions,
135
+ "files": pr.files,
136
+ "labels": ", ".join(pr.labels),
137
+ "ci": pr.ci,
138
+ }
139
+ if column not in values:
140
+ raise ValueError("Unknown dashboard column")
141
+ return values[column]
142
+
143
+
144
+ def sort_rows(rows: tuple[DashboardRow, ...], sort: SortSpec) -> tuple[DashboardRow, ...]:
145
+ if sort.column not in COLUMN_IDS:
146
+ raise ValueError("Unknown dashboard sort column")
147
+ present = [row for row in rows if column_value(row, sort.column) is not None]
148
+ missing = [row for row in rows if column_value(row, sort.column) is None]
149
+
150
+ def key(row: DashboardRow) -> str | int | float:
151
+ value = column_value(row, sort.column)
152
+ assert value is not None
153
+ return value
154
+
155
+ ordered = sorted(present, key=key, reverse=sort.descending) + missing
156
+ # Keep post-approval changes last regardless of sort column or direction.
157
+ return tuple(sorted(ordered, key=lambda row: row.row.tracked.state == "DONE_PLUS"))
158
+
159
+
160
+ async def dashboard_rows(
161
+ path: Path | None, view: str = "active", text: str = ""
162
+ ) -> tuple[DashboardRow, ...]:
163
+ async with local_store(path) as (queue, settings):
164
+ selected: Literal["active", "requested", "following"] = (
165
+ "requested" if view == "requested" else "following" if view == "following" else "active"
166
+ )
167
+ rows = await list_queue(
168
+ queue,
169
+ view=selected,
170
+ include_own_prs=settings.ui.include_own_prs,
171
+ include_assigned_prs=settings.ui.include_assigned_prs,
172
+ )
173
+ store = SQLiteReviewStore(queue.database)
174
+ reviews = ReviewService(store, ReviewPolicy(frozenset()))
175
+ result: list[DashboardRow] = []
176
+ for row in rows:
177
+ if row.tracked.state == "DONE":
178
+ continue
179
+ if view == "triage" and row.tracked.state != "FINDINGS_TRIAGE":
180
+ continue
181
+ if view == "posted" and row.tracked.state != "PENDING_AUTHOR_CHANGES":
182
+ continue
183
+ pr = row.tracked.pull_request
184
+ searchable = (
185
+ f"{pr.ref.namespace}/{pr.ref.repository}#{pr.ref.number} {pr.title} {pr.author}"
186
+ )
187
+ if text.lower() not in searchable.lower():
188
+ continue
189
+ run = await store.latest_run(row.tracked.task_id) if row.tracked.task_id else None
190
+ analysis = await reviews.get_result(run.id) if run else None
191
+ session = await store.get_session(run.session_id) if run else None
192
+ findings = analysis.document.findings if analysis and analysis.document else None
193
+ result.append(
194
+ DashboardRow(
195
+ row=row,
196
+ run_status=run.status if run else "",
197
+ reviewed_sha=run.head_sha if run else "",
198
+ session_name=session.display_name if session else "",
199
+ findings=len(findings) if findings is not None else None,
200
+ max_severity=max(
201
+ (finding.severity for finding in findings),
202
+ key=lambda value: SEVERITIES[value],
203
+ default="",
204
+ )
205
+ if findings
206
+ else "",
207
+ unverified=len(analysis.document.orientation.unverified)
208
+ if analysis and analysis.document
209
+ else 0,
210
+ )
211
+ )
212
+ weights = settings.ui.attention
213
+
214
+ def attention(row: DashboardRow) -> float:
215
+ pr = row.row.tracked.pull_request
216
+ severity = {
217
+ "blocking": weights.blocking,
218
+ "important": weights.important,
219
+ "minor": 10,
220
+ }.get(row.max_severity, 0)
221
+ state = {"FINDINGS_TRIAGE": 50, "PENDING_HUMAN_REVIEW": 20}.get(
222
+ row.row.tracked.state, 0
223
+ )
224
+ if row.run_status == "failed":
225
+ state = 30
226
+ age = max(0, (datetime.now(UTC) - pr.created_at).total_seconds() / 86400)
227
+ size = ((pr.additions or 0) + (pr.deletions or 0)) / 100
228
+ return (
229
+ severity
230
+ + state
231
+ + age * weights.age_pressure_per_day
232
+ + size * weights.size_penalty_per_100_lines
233
+ - (20 if pr.is_draft else 0)
234
+ )
235
+
236
+ return tuple(row.model_copy(update={"attention": attention(row)}) for row in result)
237
+
238
+
239
+ async def history_configured(path: Path | None, filters: HistoryFilter) -> tuple[CycleRecord, ...]:
240
+ async with local_store(path) as (queue, _):
241
+ return await DashboardService(
242
+ SQLiteDashboardStore(queue.database), SQLiteReviewStore(queue.database)
243
+ ).history(filters)
244
+
245
+
246
+ async def detail_configured(path: Path | None, task_id: UUID) -> CycleDetail:
247
+ async with local_store(path) as (queue, _):
248
+ return await DashboardService(
249
+ SQLiteDashboardStore(queue.database), SQLiteReviewStore(queue.database)
250
+ ).detail(task_id)
251
+
252
+
253
+ async def state_configured(
254
+ path: Path | None, value: str, login: str | None, *, waiting: bool
255
+ ) -> str:
256
+ async with local_store(path) as (queue, _):
257
+ row = await resolve_local(queue, value, login)
258
+ assert row.tracked.task_id is not None
259
+ service = DashboardService(
260
+ SQLiteDashboardStore(queue.database), SQLiteReviewStore(queue.database)
261
+ )
262
+ if waiting:
263
+ await service.mark_waiting_for_author(row.tracked.task_id)
264
+ else:
265
+ await service.continue_manual_review(row.tracked.task_id)
266
+ return row.tracked.pull_request.url
267
+
268
+
269
+ async def refresh_configured(path: Path | None, value: str, login: str | None) -> PullRequestRow:
270
+ """Refresh one PR, preserving a durable pending indication on any failure."""
271
+ async with local_store(path) as (queue, settings):
272
+ row = await resolve_local(queue, value, login)
273
+ assert row.tracked.task_id is not None
274
+ await SQLiteDashboardStore(queue.database).refresh_pending(row.tracked.task_id)
275
+ for index, config in enumerate(settings.forges, 1):
276
+ if f"{config.kind}:{config.base_url.rstrip('/')}" != row.account.forge:
277
+ continue
278
+ if config.login and config.login.lower() != row.account.login.lower():
279
+ continue
280
+ async with remote_service(queue, settings, index) as service:
281
+ if (await service.forge.whoami()).login.lower() != row.account.login.lower():
282
+ continue
283
+ service.expected_login = row.account.login
284
+ return await service.follow_pr(row.tracked.pull_request.ref)
285
+ raise ForgeError("Refresh pending: no matching configured forge account")
286
+
287
+
288
+ async def poll_configured(path: Path | None) -> None:
289
+ reports = await sync_configured(path)
290
+ if any(not report.complete for report in reports):
291
+ raise ForgeError(
292
+ "Refresh pending: " + "; ".join(w for report in reports for w in report.warnings)
293
+ )
294
+
295
+
296
+ async def preferences_configured(path: Path | None) -> dict[str, str]:
297
+ async with local_store(path) as (queue, _):
298
+ return await SQLiteDashboardStore(queue.database).preferences()
299
+
300
+
301
+ async def save_sort_configured(path: Path | None, sort: SortSpec) -> None:
302
+ async with local_store(path) as (queue, _):
303
+ store = SQLiteDashboardStore(queue.database)
304
+ await store.set_preference("sort_by", sort.column)
305
+ await store.set_preference("sort_desc", str(sort.descending).lower())
306
+
307
+
308
+ async def cancel_task_configured(path: Path | None, task_id: UUID) -> None:
309
+ async with local_store(path) as (queue, _):
310
+ store = SQLiteReviewStore(queue.database)
311
+ run = await store.latest_run(task_id)
312
+ if run is not None:
313
+ await store.cancel_review(run.id)
314
+
315
+
316
+ async def diff_configured(path: Path | None, task_id: UUID) -> str:
317
+ """Explicit source inspection of the saved run's pinned pair; no project execution."""
318
+ from diffbutler.reviewers.redaction import redact_and_report
319
+ from diffbutler.sources.git import GitSourceReader
320
+
321
+ async with local_store(path) as (queue, settings):
322
+ detail = await DashboardService(
323
+ SQLiteDashboardStore(queue.database), SQLiteReviewStore(queue.database)
324
+ ).detail(task_id)
325
+ if not detail.cycle.runs:
326
+ raise ReviewConflict("No reviewed revision is available")
327
+ pr = detail.cycle.pull_request
328
+ repo = settings.repos.get(f"{pr.ref.namespace}/{pr.ref.repository}")
329
+ run = detail.cycle.runs[-1]
330
+ from diffbutler.core.native import session_directory
331
+ from diffbutler.reviewers.resources import OwnedDirectories
332
+
333
+ store = SQLiteReviewStore(queue.database)
334
+ resources = await store.session_resources(run.session_id)
335
+ managed_path: Path | None = None
336
+ if resources:
337
+ preset = next((r.preset for r in settings.reviewers if r.id == run.reviewer_id), None)
338
+ root = await session_directory(
339
+ store,
340
+ await OwnedDirectories.reopen(settings.general.workspace_root),
341
+ run.session_id,
342
+ preset or "codex",
343
+ )
344
+ candidate = root / f"source-{run.id}.git"
345
+ if candidate.is_dir():
346
+ managed_path = candidate
347
+ source_record = root / f"source-{run.id}.json"
348
+ if source_record.is_file():
349
+ import json
350
+
351
+ managed_path = Path(json.loads(source_record.read_text())["repository"])
352
+ repository_path = managed_path or (repo.path if repo else None)
353
+ if repository_path is None:
354
+ raise ReviewConflict("Pinned source is unavailable or has been removed by cleanup")
355
+ source = await GitSourceReader.open(
356
+ repository_path, base_sha=run.base_sha, head_sha=run.head_sha
357
+ )
358
+ diff = await source.read_diff()
359
+ return redact_and_report(diff.encode()).decode()
360
+
361
+
362
+ async def log_configured(path: Path | None, task_id: UUID) -> str:
363
+ async with local_store(path) as (queue, _):
364
+ store = SQLiteReviewStore(queue.database)
365
+ run = await store.latest_run(task_id)
366
+ result = await store.get_result(run.id) if run else None
367
+ if result is None or result.stdout is None:
368
+ return "Raw output is unavailable or has been removed by cleanup."
369
+ return (
370
+ result.stdout.decode(errors="replace")
371
+ + "\n"
372
+ + (result.stderr or b"").decode(errors="replace")
373
+ )
374
+
375
+
376
+ async def pause_configured(path: Path | None, paused: bool) -> None:
377
+ async with local_store(path) as (queue, _):
378
+ await SQLiteDashboardStore(queue.database).set_preference(
379
+ "reviews_paused", str(paused).lower()
380
+ )
381
+
382
+
383
+ async def toggle_monitoring_configured(path: Path | None) -> bool:
384
+ """Persist discovery scope without starting reviews or contacting a forge."""
385
+ async with local_store(path) as (queue, settings):
386
+ store = SQLiteDashboardStore(queue.database)
387
+ enabled = (await store.preferences()).get("monitor_all_prs") != "true"
388
+ if enabled and (
389
+ not settings.forges or any(not forge.repositories for forge in settings.forges)
390
+ ):
391
+ raise ForgeError("Set repositories for each forge before monitoring all open PRs")
392
+ await store.set_preference("monitor_all_prs", str(enabled).lower())
393
+ return enabled