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,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
+ )
@@ -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
+ )