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
diffbutler/core/setup.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""First-run configuration with read-only forge validation; no database or AI operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from diffbutler.config.settings import (
|
|
13
|
+
NATIVE_PRESETS,
|
|
14
|
+
ConfigError,
|
|
15
|
+
Forge,
|
|
16
|
+
Reviewer,
|
|
17
|
+
Settings,
|
|
18
|
+
config_path,
|
|
19
|
+
load_settings,
|
|
20
|
+
)
|
|
21
|
+
from diffbutler.config.setup import save_setup
|
|
22
|
+
from diffbutler.domain.forge import PRQuery, identity_part
|
|
23
|
+
from diffbutler.forges.base import ForgeError
|
|
24
|
+
from diffbutler.forges.github.setup import detect_login, repository_is_public, validate_access
|
|
25
|
+
from diffbutler.sources.remotes import discover_repository
|
|
26
|
+
|
|
27
|
+
SETUP_CHECK_TIMEOUT = 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def startup_settings(
|
|
31
|
+
path: Path | None,
|
|
32
|
+
*,
|
|
33
|
+
directory: Path,
|
|
34
|
+
ask: Callable[[str, str], Awaitable[str]] | None,
|
|
35
|
+
progress: Callable[[str], None] | None = None,
|
|
36
|
+
) -> Settings:
|
|
37
|
+
settings = await asyncio.to_thread(load_settings, path, missing_ok=True)
|
|
38
|
+
new_config = not (path or config_path()).expanduser().exists()
|
|
39
|
+
|
|
40
|
+
async def check[T](label: str, operation: Awaitable[T]) -> T:
|
|
41
|
+
logging.getLogger(__name__).info("Startup check: %s", label)
|
|
42
|
+
if progress is not None:
|
|
43
|
+
progress(label + "… (Ctrl+C to cancel)")
|
|
44
|
+
try:
|
|
45
|
+
async with asyncio.timeout(SETUP_CHECK_TIMEOUT):
|
|
46
|
+
return await operation
|
|
47
|
+
except TimeoutError:
|
|
48
|
+
raise ConfigError(
|
|
49
|
+
"Startup check timed out after 30 seconds; check network access and retry. "
|
|
50
|
+
"No setup changes were saved."
|
|
51
|
+
) from None
|
|
52
|
+
except ForgeError as exc:
|
|
53
|
+
if exc.retry_after is not None:
|
|
54
|
+
delay = math.ceil(exc.retry_after)
|
|
55
|
+
raise ForgeError(
|
|
56
|
+
f"GitHub requires a wait of {delay} seconds. Retry setup later; "
|
|
57
|
+
"resetting DiffButler does not reset GitHub quota. "
|
|
58
|
+
"No setup changes were saved.",
|
|
59
|
+
status=exc.status,
|
|
60
|
+
retry_after=exc.retry_after,
|
|
61
|
+
) from None
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
async def question(label: str, default: str = "") -> str:
|
|
65
|
+
if ask is None:
|
|
66
|
+
raise ConfigError(
|
|
67
|
+
"First-run setup requires more information; "
|
|
68
|
+
"start diffbutler in an interactive terminal or edit the config"
|
|
69
|
+
)
|
|
70
|
+
return (await ask(label, default)).strip()
|
|
71
|
+
|
|
72
|
+
if settings.forges:
|
|
73
|
+
reviewers, default = await setup_reviewer(
|
|
74
|
+
settings, directory, question, progress, discover=new_config
|
|
75
|
+
)
|
|
76
|
+
if default is not None:
|
|
77
|
+
await asyncio.to_thread(save_setup, path, None, None, reviewers, default)
|
|
78
|
+
return await asyncio.to_thread(load_settings, path)
|
|
79
|
+
return settings
|
|
80
|
+
detected = await check("Checking local repository", discover_repository(directory))
|
|
81
|
+
|
|
82
|
+
if detected is not None and detected.host == "github.com":
|
|
83
|
+
host = detected.host
|
|
84
|
+
else:
|
|
85
|
+
kind = (await question("Forge (github or github-enterprise)", "github")).lower()
|
|
86
|
+
if kind not in {"github", "github-enterprise"}:
|
|
87
|
+
raise ConfigError("Only GitHub and GitHub Enterprise are currently supported")
|
|
88
|
+
host = (
|
|
89
|
+
"github.com"
|
|
90
|
+
if kind == "github"
|
|
91
|
+
else await question("GitHub Enterprise hostname", detected.host if detected else "")
|
|
92
|
+
)
|
|
93
|
+
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host):
|
|
94
|
+
raise ConfigError("Enter a forge hostname without a URL scheme or path")
|
|
95
|
+
host = host.lower()
|
|
96
|
+
matched = detected is not None and detected.host == host
|
|
97
|
+
repository = (
|
|
98
|
+
detected.repository
|
|
99
|
+
if detected is not None and matched
|
|
100
|
+
else await question("Repository to monitor (owner/name)")
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
PRQuery(repository=repository)
|
|
104
|
+
except ValueError:
|
|
105
|
+
raise ConfigError("Repository must be owner/name") from None
|
|
106
|
+
base_url = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
|
|
107
|
+
public = await check(
|
|
108
|
+
"Checking GitHub repository visibility", repository_is_public(base_url, repository)
|
|
109
|
+
)
|
|
110
|
+
token_env: str | None = None
|
|
111
|
+
token: str | None = None
|
|
112
|
+
if progress is not None:
|
|
113
|
+
progress(
|
|
114
|
+
"Recommended: use your existing gh login with "
|
|
115
|
+
f'export GITHUB_TOKEN="$(gh auth token --hostname {host})".\n'
|
|
116
|
+
"Or create a fine-grained token at "
|
|
117
|
+
f"https://{host}/settings/personal-access-tokens "
|
|
118
|
+
"with repository permission Pull requests: Read-only.\n"
|
|
119
|
+
"Anonymous access is a last resort for public repositories: "
|
|
120
|
+
"GitHub.com allows only 60 requests/hour per IP."
|
|
121
|
+
)
|
|
122
|
+
access = "token"
|
|
123
|
+
if public:
|
|
124
|
+
access = (await question("GitHub access (token recommended, anonymous)", "token")).lower()
|
|
125
|
+
if access not in {"token", "anonymous"}:
|
|
126
|
+
raise ConfigError("Choose token or anonymous access")
|
|
127
|
+
if access == "token":
|
|
128
|
+
token_env = await question("GitHub token environment variable name", "GITHUB_TOKEN")
|
|
129
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token_env):
|
|
130
|
+
raise ConfigError("Enter an environment variable name, not a token value")
|
|
131
|
+
token = os.environ.get(token_env)
|
|
132
|
+
if not token:
|
|
133
|
+
raise ConfigError("The selected token environment variable is unset or empty")
|
|
134
|
+
login = (
|
|
135
|
+
await check("Detecting GitHub username", detect_login(base_url, token))
|
|
136
|
+
if token is not None
|
|
137
|
+
else None
|
|
138
|
+
)
|
|
139
|
+
if login is None:
|
|
140
|
+
login = await question("GitHub username for review requests", repository.split("/")[0])
|
|
141
|
+
else:
|
|
142
|
+
logging.getLogger(__name__).info("Detected GitHub username: %s", login)
|
|
143
|
+
try:
|
|
144
|
+
identity_part(login)
|
|
145
|
+
except ValueError:
|
|
146
|
+
raise ConfigError("Enter a valid GitHub username") from None
|
|
147
|
+
login = await check(
|
|
148
|
+
"Checking GitHub username and PR access",
|
|
149
|
+
validate_access(base_url, repository, login, token),
|
|
150
|
+
)
|
|
151
|
+
forge = Forge(
|
|
152
|
+
kind="github",
|
|
153
|
+
base_url=base_url,
|
|
154
|
+
token_env=token_env,
|
|
155
|
+
login=login,
|
|
156
|
+
include_team_requests=not public,
|
|
157
|
+
repositories=[repository],
|
|
158
|
+
)
|
|
159
|
+
reviewers, default = await setup_reviewer(
|
|
160
|
+
settings, directory, question, progress, discover=new_config
|
|
161
|
+
)
|
|
162
|
+
await asyncio.to_thread(
|
|
163
|
+
save_setup,
|
|
164
|
+
path,
|
|
165
|
+
forge,
|
|
166
|
+
detected.path if detected and matched else None,
|
|
167
|
+
reviewers,
|
|
168
|
+
default,
|
|
169
|
+
)
|
|
170
|
+
return await asyncio.to_thread(load_settings, path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def check_reviewer(reviewer: Reviewer, directory: Path) -> None:
|
|
174
|
+
if shutil.which("git") is None:
|
|
175
|
+
raise ConfigError(
|
|
176
|
+
"Git is required to prepare PR sources; install Git and restart DiffButler"
|
|
177
|
+
)
|
|
178
|
+
if shutil.which(reviewer.command[0]) is None:
|
|
179
|
+
raise ConfigError("Reviewer executable was not found; install it or enter its full path")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def setup_reviewer(
|
|
183
|
+
settings: Settings,
|
|
184
|
+
directory: Path,
|
|
185
|
+
question: Callable[[str, str], Awaitable[str]],
|
|
186
|
+
progress: Callable[[str], None] | None = None,
|
|
187
|
+
*,
|
|
188
|
+
discover: bool = False,
|
|
189
|
+
) -> tuple[tuple[Reviewer, ...], str | None]:
|
|
190
|
+
current = next(
|
|
191
|
+
(r for r in settings.reviewers if r.id == settings.review.default_reviewer), None
|
|
192
|
+
)
|
|
193
|
+
if current is not None and current.preset is not None:
|
|
194
|
+
return (), None
|
|
195
|
+
native = [r for r in settings.reviewers if r.preset is not None]
|
|
196
|
+
detected: list[Reviewer] = []
|
|
197
|
+
if discover and not settings.reviewers:
|
|
198
|
+
for preset in NATIVE_PRESETS:
|
|
199
|
+
if resolved := shutil.which(preset):
|
|
200
|
+
detected.append(
|
|
201
|
+
Reviewer(id=preset, preset=preset, command=[str(Path(resolved).absolute())])
|
|
202
|
+
)
|
|
203
|
+
native = detected
|
|
204
|
+
if native:
|
|
205
|
+
choices = ", ".join(r.id for r in native)
|
|
206
|
+
name = await question(f"Default AI reviewer ({choices})", native[0].id)
|
|
207
|
+
chosen = next((r for r in native if r.id == name), None)
|
|
208
|
+
if chosen is None:
|
|
209
|
+
raise ConfigError("Choose one of the configured native reviewers")
|
|
210
|
+
if progress is not None:
|
|
211
|
+
progress("Checking Git and reviewer executable… (Ctrl+C to cancel)")
|
|
212
|
+
await check_reviewer(chosen, directory)
|
|
213
|
+
return tuple(detected), chosen.id
|
|
214
|
+
name = (await question("AI reviewer (codex or claude)", "codex")).lower()
|
|
215
|
+
if name not in {"codex", "claude"}:
|
|
216
|
+
raise ConfigError("Choose codex or claude as the AI reviewer")
|
|
217
|
+
executable = await question("Reviewer executable (name or full path)", name)
|
|
218
|
+
resolved = shutil.which(executable)
|
|
219
|
+
if resolved is None:
|
|
220
|
+
raise ConfigError("Reviewer executable was not found; install it or enter its full path")
|
|
221
|
+
identifier = name
|
|
222
|
+
while any(r.id == identifier for r in settings.reviewers):
|
|
223
|
+
identifier += "-native"
|
|
224
|
+
reviewer = Reviewer(
|
|
225
|
+
id=identifier,
|
|
226
|
+
command=[str(Path(resolved).absolute())],
|
|
227
|
+
preset="codex" if name == "codex" else "claude",
|
|
228
|
+
)
|
|
229
|
+
if progress is not None:
|
|
230
|
+
progress("Checking Git and reviewer executable… (Ctrl+C to cancel)")
|
|
231
|
+
await check_reviewer(reviewer, directory)
|
|
232
|
+
return (reviewer,), reviewer.id
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Materialize pinned, redacted Git blobs as text/data, never run checkout hooks."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import aclosing
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from diffbutler.domain.redaction import redact_source
|
|
10
|
+
from diffbutler.domain.review import Side, SourceFile
|
|
11
|
+
from diffbutler.reviewers.resources import settled_io
|
|
12
|
+
from diffbutler.sources.git import GitSourceReader
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def write_snapshot(
|
|
16
|
+
reader: GitSourceReader,
|
|
17
|
+
destination: Path,
|
|
18
|
+
*,
|
|
19
|
+
progress: Callable[[str], None] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Stream unique blobs into a new owned directory, with one redaction per blob.
|
|
22
|
+
|
|
23
|
+
Symlinks become regular files containing link text; submodules fail. Files
|
|
24
|
+
are independent copies, without Git administration or executable modes.
|
|
25
|
+
"""
|
|
26
|
+
total = sum(len(reader.paths(side)) for side in Side)
|
|
27
|
+
completed = objects = redactions = 0
|
|
28
|
+
started = last_report = time.monotonic()
|
|
29
|
+
|
|
30
|
+
def report() -> None:
|
|
31
|
+
message = f"Preparing source snapshots: {completed}/{total} files ({objects} unique blobs)"
|
|
32
|
+
logging.getLogger(__name__).info("%s", message)
|
|
33
|
+
if progress is not None:
|
|
34
|
+
progress(message)
|
|
35
|
+
|
|
36
|
+
report()
|
|
37
|
+
|
|
38
|
+
def initialize() -> None:
|
|
39
|
+
destination.mkdir(mode=0o700)
|
|
40
|
+
for side in Side:
|
|
41
|
+
(destination / side.value).mkdir(mode=0o700)
|
|
42
|
+
|
|
43
|
+
await settled_io(initialize)
|
|
44
|
+
async with aclosing(reader.read_blobs()) as blobs:
|
|
45
|
+
async for sources, content in blobs:
|
|
46
|
+
|
|
47
|
+
def write(sources: tuple[SourceFile, ...] = sources, content: bytes = content) -> int:
|
|
48
|
+
redacted = redact_source(content)
|
|
49
|
+
for source in sources:
|
|
50
|
+
target = destination / source.side.value / source.path
|
|
51
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
with target.open("xb") as stream:
|
|
53
|
+
stream.write(redacted.content)
|
|
54
|
+
target.chmod(0o600)
|
|
55
|
+
return redacted.count
|
|
56
|
+
|
|
57
|
+
redactions += await settled_io(write)
|
|
58
|
+
completed += len(sources)
|
|
59
|
+
objects += 1
|
|
60
|
+
if time.monotonic() - last_report >= 5:
|
|
61
|
+
report()
|
|
62
|
+
last_report = time.monotonic()
|
|
63
|
+
report()
|
|
64
|
+
logging.getLogger(__name__).info(
|
|
65
|
+
"Source snapshots ready: files=%d blobs=%d masked=%d elapsed_s=%.3f",
|
|
66
|
+
completed,
|
|
67
|
+
objects,
|
|
68
|
+
redactions,
|
|
69
|
+
time.monotonic() - started,
|
|
70
|
+
)
|
diffbutler/core/sync.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Shared async discovery, follow-up, and browsing services; no reviewer capability."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.forge import Account, Discovery, PRPage, PRQuery, PRRef
|
|
9
|
+
from diffbutler.domain.sync import ReviewActivity, TrackedPR, reconcile
|
|
10
|
+
from diffbutler.forges.base import ForgeError, ReadForge
|
|
11
|
+
from diffbutler.store.base import QueueStore, SyncConflict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PullRequestRow(BaseModel):
|
|
15
|
+
model_config = ConfigDict(frozen=True)
|
|
16
|
+
account: Account
|
|
17
|
+
tracked: TrackedPR
|
|
18
|
+
warnings: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SyncReport(BaseModel):
|
|
22
|
+
model_config = ConfigDict(frozen=True)
|
|
23
|
+
account: Account
|
|
24
|
+
discovered: int
|
|
25
|
+
refreshed: int
|
|
26
|
+
active: int
|
|
27
|
+
complete: bool
|
|
28
|
+
warnings: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiffButler:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
store: QueueStore,
|
|
35
|
+
forge: ReadForge,
|
|
36
|
+
*,
|
|
37
|
+
expected_login: str | None = None,
|
|
38
|
+
monitor_repositories: tuple[str, ...] = (),
|
|
39
|
+
):
|
|
40
|
+
self.store = store
|
|
41
|
+
self.forge = forge
|
|
42
|
+
self.expected_login = expected_login
|
|
43
|
+
self.monitor_repositories = monitor_repositories
|
|
44
|
+
|
|
45
|
+
async def _account(self) -> Account:
|
|
46
|
+
account = await self.forge.whoami()
|
|
47
|
+
if account.forge != self.forge.id or (
|
|
48
|
+
self.expected_login is not None and account.login.lower() != self.expected_login.lower()
|
|
49
|
+
):
|
|
50
|
+
raise ForgeError("Authenticated identity does not match configured account")
|
|
51
|
+
return account
|
|
52
|
+
|
|
53
|
+
async def sync(self) -> SyncReport:
|
|
54
|
+
account = await self._account()
|
|
55
|
+
stale_results: dict[PRRef, TrackedPR] = {}
|
|
56
|
+
for attempt in range(3):
|
|
57
|
+
snapshot = await self.store.snapshot(account)
|
|
58
|
+
try:
|
|
59
|
+
discovery = await self.forge.list_review_requests()
|
|
60
|
+
except ForgeError as exc:
|
|
61
|
+
discovery = Discovery((), False, (f"Discovery failed: {exc}",))
|
|
62
|
+
warnings = list(discovery.warnings)
|
|
63
|
+
complete = discovery.complete
|
|
64
|
+
discovered = {item.pull_request.ref: item.pull_request for item in discovery.items}
|
|
65
|
+
sources: dict[PRRef, set[str]] = {}
|
|
66
|
+
for item in discovery.items:
|
|
67
|
+
sources.setdefault(item.pull_request.ref, set()).update(item.sources)
|
|
68
|
+
# Broad monitoring is independent of review-request membership.
|
|
69
|
+
for repository in self.monitor_repositories:
|
|
70
|
+
cursor: str | None = None
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
try:
|
|
73
|
+
while True:
|
|
74
|
+
page = await self.forge.search_pull_requests(
|
|
75
|
+
PRQuery(repository=repository, page_size=100), cursor
|
|
76
|
+
)
|
|
77
|
+
complete = complete and page.complete
|
|
78
|
+
warnings.extend(page.warnings)
|
|
79
|
+
for pr in page.items:
|
|
80
|
+
if (
|
|
81
|
+
pr.state == "open"
|
|
82
|
+
and pr.ref.forge == account.forge
|
|
83
|
+
and f"{pr.ref.namespace}/{pr.ref.repository}".lower()
|
|
84
|
+
== repository.lower()
|
|
85
|
+
):
|
|
86
|
+
discovered[pr.ref] = pr
|
|
87
|
+
cursor = page.next_cursor
|
|
88
|
+
if cursor is None:
|
|
89
|
+
break
|
|
90
|
+
if cursor in seen:
|
|
91
|
+
raise ForgeError("Repeated pagination cursor")
|
|
92
|
+
seen.add(cursor)
|
|
93
|
+
except ForgeError as exc:
|
|
94
|
+
complete = False
|
|
95
|
+
warnings.append(f"Open-PR monitoring is incomplete: {exc}")
|
|
96
|
+
previous = {item.pull_request.ref: item for item in snapshot.items}
|
|
97
|
+
refs = set(discovered) | {
|
|
98
|
+
ref
|
|
99
|
+
for ref, item in previous.items()
|
|
100
|
+
if item.following and item.pull_request.state == "open"
|
|
101
|
+
}
|
|
102
|
+
updated = dict(previous)
|
|
103
|
+
refreshed: set[PRRef] = set()
|
|
104
|
+
for ref in sorted(
|
|
105
|
+
refs, key=lambda ref: (ref.forge, ref.namespace, ref.repository, ref.number)
|
|
106
|
+
):
|
|
107
|
+
old = previous.get(ref)
|
|
108
|
+
stale = stale_results.get(ref)
|
|
109
|
+
if old and stale and old.manual_after != stale.manual_after:
|
|
110
|
+
# Consume evidence fetched before a newer manual choice, even
|
|
111
|
+
# if the forge's clock is ahead of this host's clock.
|
|
112
|
+
old = replace(old, manual_after=max(old.manual_after, stale.review_key))
|
|
113
|
+
membership = set(sources.get(ref, ()))
|
|
114
|
+
if not discovery.complete and old:
|
|
115
|
+
membership.update(old.sources)
|
|
116
|
+
try:
|
|
117
|
+
# Followed PRs refresh by identity even when search is empty or stale.
|
|
118
|
+
pr = await self.forge.get_pull_request(ref) if old else discovered[ref]
|
|
119
|
+
if pr.ref != ref:
|
|
120
|
+
raise ForgeError("Forge returned a different PR identity")
|
|
121
|
+
except ForgeError as exc:
|
|
122
|
+
complete = False
|
|
123
|
+
warnings.append(
|
|
124
|
+
f"Could not refresh {ref.namespace}/{ref.repository}#{ref.number}: {exc}"
|
|
125
|
+
)
|
|
126
|
+
if old:
|
|
127
|
+
updated[ref] = replace(
|
|
128
|
+
old, sources=tuple(sorted(membership)), refresh_pending=True
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
activity = await self.forge.get_review_activity(ref)
|
|
133
|
+
if activity.account != account:
|
|
134
|
+
raise ForgeError("Account changed during activity refresh")
|
|
135
|
+
except ForgeError as exc:
|
|
136
|
+
activity = ReviewActivity(account, complete=False, warnings=(str(exc),))
|
|
137
|
+
complete = complete and activity.complete
|
|
138
|
+
warnings.extend(activity.warnings)
|
|
139
|
+
updated[ref] = reconcile(old, pr, activity, tuple(sorted(membership)))
|
|
140
|
+
refreshed.add(ref)
|
|
141
|
+
# Clear stale membership only after complete discovery, including closed monitors.
|
|
142
|
+
if discovery.complete:
|
|
143
|
+
for ref, item in updated.items():
|
|
144
|
+
if ref not in refs:
|
|
145
|
+
updated[ref] = replace(item, sources=tuple(sorted(sources.get(ref, ()))))
|
|
146
|
+
if await self._account() != account:
|
|
147
|
+
raise ForgeError("Account changed during sync; no changes saved")
|
|
148
|
+
try:
|
|
149
|
+
await self.store.apply(
|
|
150
|
+
account,
|
|
151
|
+
snapshot.version,
|
|
152
|
+
tuple(updated.values()),
|
|
153
|
+
refreshed=frozenset(refreshed),
|
|
154
|
+
)
|
|
155
|
+
except SyncConflict:
|
|
156
|
+
stale_results = updated
|
|
157
|
+
if attempt == 2:
|
|
158
|
+
raise ForgeError("Queue changed concurrently; retry sync") from None
|
|
159
|
+
continue
|
|
160
|
+
return SyncReport(
|
|
161
|
+
account=account,
|
|
162
|
+
discovered=len(discovered),
|
|
163
|
+
refreshed=len(refreshed),
|
|
164
|
+
active=sum(item.state != "DONE" for item in updated.values()),
|
|
165
|
+
complete=complete,
|
|
166
|
+
warnings=tuple(dict.fromkeys(warnings)),
|
|
167
|
+
)
|
|
168
|
+
raise AssertionError("unreachable")
|
|
169
|
+
|
|
170
|
+
async def follow_pr(self, ref: PRRef) -> PullRequestRow:
|
|
171
|
+
account = await self._account()
|
|
172
|
+
stale: TrackedPR | None = None
|
|
173
|
+
for attempt in range(3):
|
|
174
|
+
snapshot = await self.store.snapshot(account)
|
|
175
|
+
previous = next((item for item in snapshot.items if item.pull_request.ref == ref), None)
|
|
176
|
+
if previous and stale and previous.manual_after != stale.manual_after:
|
|
177
|
+
previous = replace(
|
|
178
|
+
previous, manual_after=max(previous.manual_after, stale.review_key)
|
|
179
|
+
)
|
|
180
|
+
pr = await self.forge.get_pull_request(ref)
|
|
181
|
+
if pr.ref != ref:
|
|
182
|
+
raise ForgeError("Forge returned a different PR identity")
|
|
183
|
+
# Following succeeds even if activity access fails; return the warning to the UI.
|
|
184
|
+
try:
|
|
185
|
+
activity = await self.forge.get_review_activity(ref)
|
|
186
|
+
except ForgeError as exc:
|
|
187
|
+
activity = ReviewActivity(account, complete=False, warnings=(str(exc),))
|
|
188
|
+
if activity.account != account or await self._account() != account:
|
|
189
|
+
raise ForgeError("Account changed during follow; no changes saved")
|
|
190
|
+
item = reconcile(previous, pr, activity, previous.sources if previous else ())
|
|
191
|
+
item = replace(item, following=True)
|
|
192
|
+
try:
|
|
193
|
+
await self.store.apply(
|
|
194
|
+
account, snapshot.version, (item,), refreshed=frozenset({ref})
|
|
195
|
+
)
|
|
196
|
+
except SyncConflict:
|
|
197
|
+
stale = item
|
|
198
|
+
if attempt == 2:
|
|
199
|
+
raise ForgeError("Queue changed concurrently; retry follow") from None
|
|
200
|
+
continue
|
|
201
|
+
persisted = await self.store.snapshot(account)
|
|
202
|
+
result = next(row for row in persisted.items if row.pull_request.ref == ref)
|
|
203
|
+
return PullRequestRow(account=account, tracked=result, warnings=activity.warnings)
|
|
204
|
+
raise AssertionError("unreachable")
|
|
205
|
+
|
|
206
|
+
async def browse_prs(self, query: PRQuery, cursor: str | None = None) -> PRPage:
|
|
207
|
+
await self._account()
|
|
208
|
+
return await self.forge.search_pull_requests(query, cursor)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def list_queue(
|
|
212
|
+
store: QueueStore,
|
|
213
|
+
*,
|
|
214
|
+
view: Literal["active", "requested", "following"] = "active",
|
|
215
|
+
login: str | None = None,
|
|
216
|
+
include_own_prs: bool = False,
|
|
217
|
+
include_assigned_prs: bool = False,
|
|
218
|
+
) -> tuple[PullRequestRow, ...]:
|
|
219
|
+
"""Read local accounts without resolving credentials or making network requests."""
|
|
220
|
+
rows: list[PullRequestRow] = []
|
|
221
|
+
for account in await store.accounts():
|
|
222
|
+
if login is not None and account.login.lower() != login.lower():
|
|
223
|
+
continue
|
|
224
|
+
for item in (await store.snapshot(account)).items:
|
|
225
|
+
if (
|
|
226
|
+
not include_own_prs
|
|
227
|
+
and item.pull_request.author.casefold() == account.login.casefold()
|
|
228
|
+
):
|
|
229
|
+
continue
|
|
230
|
+
if not include_assigned_prs and any(
|
|
231
|
+
assignee.casefold() == account.login.casefold()
|
|
232
|
+
for assignee in item.pull_request.assignees
|
|
233
|
+
):
|
|
234
|
+
continue
|
|
235
|
+
include = (
|
|
236
|
+
item.state != "DONE"
|
|
237
|
+
if view == "active"
|
|
238
|
+
else any(
|
|
239
|
+
source == f"user:{account.login.lower()}" or source.startswith("team:")
|
|
240
|
+
for source in item.sources
|
|
241
|
+
)
|
|
242
|
+
if view == "requested"
|
|
243
|
+
else item.following
|
|
244
|
+
)
|
|
245
|
+
if include:
|
|
246
|
+
rows.append(PullRequestRow(account=account, tracked=item))
|
|
247
|
+
return tuple(
|
|
248
|
+
sorted(
|
|
249
|
+
rows,
|
|
250
|
+
key=lambda row: (
|
|
251
|
+
row.tracked.state == "DONE_PLUS",
|
|
252
|
+
row.account.forge,
|
|
253
|
+
row.account.login,
|
|
254
|
+
row.tracked.pull_request.ref.namespace,
|
|
255
|
+
row.tracked.pull_request.ref.repository,
|
|
256
|
+
row.tracked.pull_request.ref.number,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
)
|