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