tsugite-forge 0.27.0__tar.gz

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.
@@ -0,0 +1,201 @@
1
+ # ---> Python
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ /lib/
19
+ /lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
165
+ .env
166
+ .env
167
+ benchmark_results/
168
+ test_output/
169
+ .claude/settings.local.json
170
+ std*.txt
171
+ secrets/*
172
+
173
+
174
+ # TODO: temp - I need to clean up the docs
175
+ docs-old/
176
+ docs/design/
177
+ examples/*
178
+ !examples/tsugite-example-plugin/
179
+ /agents/
180
+ .claude/
181
+ .tsugite/
182
+ benchmarks/
183
+ docker-compose.test.yml
184
+ #### TODO ^^^
185
+
186
+ # Vite dep-optimizer cache. Normally under node_modules/.vite, but lands at the
187
+ # cwd's `.vite/` when vite/vitest is run from the repo root, so ignore it anywhere.
188
+ .vite/
189
+ # Built web UI - generated by `mise run web-build`; the wheel bundles it via
190
+ # hatch `artifacts`, git never carries it.
191
+ plugins/tsugite-daemon/tsugite_daemon/web/
192
+ # Re-include frontend source that generic lib/ rules (incl. contributors' global
193
+ # gitignores with Python-style `lib/`) would otherwise silently exclude.
194
+ !plugins/tsugite-daemon/frontend/src/lib/
195
+ !plugins/tsugite-daemon/frontend/src/lib/**
196
+ # Per-job git worktrees provisioned by the daemon.
197
+ .tsugite-jobs/
198
+ /packaging/runtime/.cache/
199
+ # Corporate CA certs dropped in for docker build.
200
+ /docker/ca-certificates/*
201
+ !/docker/ca-certificates/.gitkeep
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: tsugite-forge
3
+ Version: 0.27.0
4
+ Summary: Tsugite plugin: read pull requests from Forgejo and GitHub
5
+ Author: Justyn Shull
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: tsugite-cli==0.27.0
8
+ Requires-Dist: tsugite-daemon==0.27.0
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "tsugite-forge"
3
+ version = "0.27.0"
4
+ description = "Tsugite plugin: read pull requests from Forgejo and GitHub"
5
+ authors = [{ name = "Justyn Shull" }]
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "tsugite-cli==0.27.0",
9
+ "tsugite-daemon==0.27.0",
10
+ ]
11
+
12
+ [project.entry-points."tsugite.plugins"]
13
+ forge = "tsugite_forge.tools"
14
+
15
+ [project.entry-points."tsugite.adapters"]
16
+ forge = "tsugite_forge.adapter:create_adapter"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["tsugite_forge"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = [
27
+ "/tsugite_forge",
28
+ "/pyproject.toml",
29
+ ]
30
+
31
+ [tool.uv.sources]
32
+ tsugite-cli = { workspace = true }
33
+ tsugite-daemon = { workspace = true }
@@ -0,0 +1 @@
1
+ """Tsugite plugin: read pull requests from Forgejo and GitHub."""
@@ -0,0 +1,407 @@
1
+ """The forge plugin's daemon adapter, which polls each forge and caches its open pull requests."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import time
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import UTC, datetime
9
+
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse
12
+ from starlette.routing import Route
13
+ from tsugite_daemon.adapters.base import BaseAdapter
14
+
15
+ from tsugite.link_previews import register_link_resolver
16
+ from tsugite.secrets.registry import get_registry
17
+ from tsugite_forge import previews, refs
18
+ from tsugite_forge.cache import LruCache
19
+ from tsugite_forge.config import REPO_PATTERN, Forge, ForgePluginConfig
20
+ from tsugite_forge.providers import ForgeError, Provider, PullRequest, kinds
21
+ from tsugite_forge.tools import INCLUDE_PARTS, set_forge_adapter
22
+
23
+ MAX_BACKOFF_SECONDS = 3600
24
+ # Seconds a checkout's origin URL is reused.
25
+ ORIGIN_TTL = 300
26
+ # Seconds live checks and review threads are reused for one head commit.
27
+ DETAIL_TTL = 60
28
+ CACHE_ENTRIES = 256
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class ForgeCache:
35
+ config: Forge
36
+ provider: Provider
37
+ repos: list[str] = field(default_factory=list)
38
+ prs: list[PullRequest] = field(default_factory=list)
39
+ last_refresh: float | None = None
40
+ error: str | None = None
41
+ failures: int = 0
42
+ next_attempt: float = 0.0
43
+ viewer: str | None = None
44
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
45
+
46
+
47
+ def _describe(exc: Exception) -> str:
48
+ return get_registry().mask(str(exc) if isinstance(exc, ForgeError) else f"{type(exc).__name__}: {exc}")
49
+
50
+
51
+ def _iso(timestamp: float | None) -> str | None:
52
+ return None if timestamp is None else datetime.fromtimestamp(timestamp, UTC).isoformat()
53
+
54
+
55
+ class ForgeAdapter(BaseAdapter):
56
+ def __init__(self, config: ForgePluginConfig, clock=time.time, session_store=None):
57
+ # Not agent-scoped, so BaseAdapter.__init__ is skipped. The gateway
58
+ # overwrites event_bus with the SSE broadcaster.
59
+ self.config = config
60
+ self.event_bus = None
61
+ self.session_store = session_store
62
+ self.loop: asyncio.AbstractEventLoop | None = None
63
+ self.clock = clock
64
+ self.interval = config.poll_minutes * 60
65
+ self.forges = {forge.name: ForgeCache(forge, kinds[forge.kind](forge)) for forge in config.forges}
66
+ self._pollers: list[asyncio.Task] = []
67
+ self._derived = LruCache(CACHE_ENTRIES)
68
+ self._origins = LruCache(CACHE_ENTRIES)
69
+ self._details = LruCache(CACHE_ENTRIES)
70
+
71
+ async def start(self) -> None:
72
+ self.loop = asyncio.get_running_loop()
73
+ self._pollers = [asyncio.create_task(self._poll(cache)) for cache in self.forges.values()]
74
+ set_forge_adapter(self)
75
+ logger.info("forge adapter started (%s)", ", ".join(self.forges))
76
+
77
+ async def stop(self) -> None:
78
+ set_forge_adapter(None)
79
+ for task in self._pollers:
80
+ task.cancel()
81
+ for task in self._pollers:
82
+ with contextlib.suppress(asyncio.CancelledError):
83
+ await task
84
+ self._pollers = []
85
+ logger.info("forge adapter stopped")
86
+
87
+ async def _poll(self, cache: ForgeCache) -> None:
88
+ while True:
89
+ if self.clock() >= cache.next_attempt:
90
+ await self._refresh(cache)
91
+ await asyncio.sleep(max(cache.next_attempt - self.clock(), 1))
92
+
93
+ async def refresh(self) -> None:
94
+ await asyncio.gather(*(self._refresh(cache) for cache in self.forges.values()))
95
+
96
+ async def _refresh(self, cache: ForgeCache) -> None:
97
+ async with cache.lock:
98
+ try:
99
+ repos = await asyncio.to_thread(cache.provider.list_repos)
100
+ prs = await asyncio.to_thread(cache.provider.list_prs, repos)
101
+ # Catch everything. An error stays on its forge and must not end the poller.
102
+ except Exception as exc:
103
+ cache.error = _describe(exc)
104
+ cache.failures += 1
105
+ backoff = self.interval * 2**cache.failures
106
+ cache.next_attempt = self.clock() + min(backoff, max(MAX_BACKOFF_SECONDS, self.interval))
107
+ logger.warning(
108
+ "forge %s refresh failed (%d in a row): %s", cache.config.name, cache.failures, cache.error
109
+ )
110
+ return
111
+ cache.repos, cache.prs = repos, prs
112
+ cache.last_refresh = self.clock()
113
+ cache.error = None
114
+ cache.failures = 0
115
+ cache.next_attempt = cache.last_refresh + self.interval
116
+ if cache.viewer is None:
117
+ try:
118
+ cache.viewer = await asyncio.to_thread(cache.provider.viewer)
119
+ except Exception as exc:
120
+ cache.error = f"viewer lookup failed: {_describe(exc)}"
121
+ logger.warning("forge %s %s", cache.config.name, cache.error)
122
+
123
+ def prs(
124
+ self,
125
+ state: str = "open",
126
+ repo: str | None = None,
127
+ author: str | None = None,
128
+ label: str | None = None,
129
+ needs_review: bool | None = None,
130
+ forge: str | None = None,
131
+ ) -> list[PullRequest]:
132
+ """Cached pull requests matching every given filter, most recently updated first."""
133
+ if state != "open":
134
+ raise ValueError(f"state must be 'open', got {state!r}. Only open pull requests are cached.")
135
+ if repo and ":" in repo:
136
+ forge, repo = repo.split(":", 1)
137
+ if forge is not None:
138
+ self.forge(forge)
139
+ matches = [
140
+ pr
141
+ for cache in self.forges.values()
142
+ if forge in (None, cache.config.name)
143
+ for pr in cache.prs
144
+ if repo in (None, pr.repo)
145
+ and author in (None, pr.author)
146
+ and (label is None or label in pr.labels)
147
+ and (needs_review is None or (cache.viewer in pr.requested_reviewers) == needs_review)
148
+ ]
149
+ return sorted(matches, key=lambda pr: pr.updated_at, reverse=True)
150
+
151
+ def resolve(self, spec: str) -> tuple[ForgeCache, str]:
152
+ """The one forge whose cached repos include `owner/repo`, or `forge:owner/repo`."""
153
+ forge, repo = spec.split(":", 1) if ":" in spec else (None, spec)
154
+ matches = [
155
+ cache for cache in self.forges.values() if forge in (None, cache.config.name) and repo in cache.repos
156
+ ]
157
+ if forge is not None:
158
+ self.forge(forge)
159
+ if not matches:
160
+ raise ValueError(f"{repo} is not in any configured forge's repos (forge_repos lists them)")
161
+ if len(matches) > 1:
162
+ options = ", ".join(f"{cache.config.name}:{repo}" for cache in matches)
163
+ raise ValueError(f"{repo} is on more than one forge, pass one of {options}")
164
+ return matches[0], repo
165
+
166
+ def pinned(self, session_id: str, spec: str, pr: int | None) -> tuple[str, str]:
167
+ """The forge and repo of the session's pin on `owner/repo` or `forge:owner/repo` and `pr`."""
168
+ forge, repo = spec.split(":", 1) if ":" in spec else (None, spec)
169
+ stored = refs.stored_refs(self._session(session_id).metadata.get("refs"))
170
+ forges = sorted(
171
+ {
172
+ e["forge"]
173
+ for e in stored
174
+ if e.get("source") == "pinned" and (e["repo"], e["pr"]) == (repo, pr) and forge in (None, e["forge"])
175
+ }
176
+ )
177
+ if not forges:
178
+ raise ValueError(f"{spec}{'' if pr is None else f'#{pr}'} is not pinned to this session")
179
+ if len(forges) > 1:
180
+ options = ", ".join(f"{name}:{repo}" for name in forges)
181
+ raise ValueError(f"{repo} is pinned on more than one forge, pass one of {options}")
182
+ return forges[0], repo
183
+
184
+ def forge(self, name: str) -> ForgeCache:
185
+ if name not in self.forges:
186
+ raise ValueError(f"no forge named {name!r}, configured: {', '.join(self.forges)}")
187
+ return self.forges[name]
188
+
189
+ def pr_detail(self, forge: str, repo: str, number: int, include=INCLUDE_PARTS) -> dict:
190
+ """One pull request, plus the `include` parts read live from the forge. Blocks on forge calls."""
191
+ unknown = set(include) - set(INCLUDE_PARTS)
192
+ if unknown:
193
+ raise ValueError(f"unknown include {', '.join(sorted(unknown))}, choose from {', '.join(INCLUDE_PARTS)}")
194
+ cache = self.forge(forge)
195
+ # Only a cached repo name may reach the provider's URLs.
196
+ if repo not in cache.repos:
197
+ raise LookupError(f"{repo} is not in forge {forge!r}'s repos")
198
+ provider = cache.provider
199
+ pr = next((pr for pr in cache.prs if pr.repo == repo and pr.number == number), None)
200
+ pr = pr or provider.get_pr(repo, number)
201
+ live = {
202
+ "checks": lambda: provider.checks(repo, number),
203
+ "threads": lambda: asdict(provider.review_threads(repo, number)),
204
+ }
205
+ detail = {"pr": asdict(pr)}
206
+ for part in include:
207
+ if part == "diff":
208
+ detail[part] = provider.diff(repo, number)
209
+ continue
210
+ key = (forge, repo, number, pr.head_sha, part)
211
+ hit = self._details.get(key)
212
+ if hit is None or self.clock() - hit[1] > DETAIL_TTL:
213
+ hit = (live[part](), self.clock())
214
+ self._details.put(key, hit)
215
+ detail[part] = hit[0]
216
+ return get_registry().mask_obj(detail)
217
+
218
+ def link_preview(self, url: str) -> dict | None:
219
+ """Preview a pull request or issue link into a configured repo. Blocks on a forge call on a cache miss."""
220
+ ref = refs.link_ref(url, self.config.forges)
221
+ if ref is None:
222
+ return None
223
+ forge, repo, kind, number = ref
224
+ cache = self.forges[forge]
225
+ if repo not in cache.repos:
226
+ return None
227
+ is_pr = kind != "issues"
228
+ if is_pr and (pr := next((pr for pr in cache.prs if pr.repo == repo and pr.number == number), None)):
229
+ return previews.pr_preview(pr, cache.config)
230
+ key = (forge, repo, kind, number)
231
+ hit = self._details.get(key)
232
+ if hit and self.clock() - hit[1] <= DETAIL_TTL:
233
+ return hit[0]
234
+ try:
235
+ if is_pr:
236
+ preview = previews.pr_preview(cache.provider.get_pr(repo, number), cache.config)
237
+ else:
238
+ preview = previews.issue_preview(cache.provider.get_issue(repo, number), cache.config)
239
+ except ForgeError as exc:
240
+ logger.info("forge %s has no preview for %s: %s", forge, url, exc)
241
+ return None
242
+ # Kept for the next request when this read outlives the daemon's resolve timeout.
243
+ self._details.put(key, (preview, self.clock()))
244
+ return preview
245
+
246
+ def get_http_routes(self) -> list:
247
+ return [
248
+ Route("/prs", self._list_prs, methods=["GET"]),
249
+ Route("/prs/{forge}/{owner}/{repo}/{number:int}", self._get_pr, methods=["GET"]),
250
+ Route("/status", self._status, methods=["GET"]),
251
+ Route("/refresh", self._refresh_now, methods=["POST"]),
252
+ Route("/sessions/{session_id}/refs", self._get_refs, methods=["GET"]),
253
+ Route("/sessions/{session_id}/refs", self._post_refs, methods=["POST"]),
254
+ ]
255
+
256
+ async def _list_prs(self, request: Request) -> JSONResponse:
257
+ query = request.query_params
258
+ needs_review = query.get("needs_review")
259
+ if needs_review not in (None, "true", "false"):
260
+ return JSONResponse({"error": "needs_review must be true or false"}, status_code=400)
261
+ try:
262
+ prs = self.prs(
263
+ state=query.get("state", "open"),
264
+ repo=query.get("repo"),
265
+ author=query.get("author"),
266
+ label=query.get("label"),
267
+ needs_review=None if needs_review is None else needs_review == "true",
268
+ forge=query.get("forge"),
269
+ )
270
+ except ValueError as exc:
271
+ return JSONResponse({"error": str(exc)}, status_code=400)
272
+ return JSONResponse(get_registry().mask_obj({"prs": [asdict(pr) for pr in prs]}))
273
+
274
+ async def _get_pr(self, request: Request) -> JSONResponse:
275
+ params = request.path_params
276
+ if params["forge"] not in self.forges:
277
+ return JSONResponse({"error": f"no forge named {params['forge']!r}"}, status_code=404)
278
+ include = request.query_params.get("include")
279
+ parts = INCLUDE_PARTS if include is None else [part for part in include.split(",") if part]
280
+ repo = f"{params['owner']}/{params['repo']}"
281
+ try:
282
+ detail = await asyncio.to_thread(self.pr_detail, params["forge"], repo, params["number"], parts)
283
+ except LookupError as exc:
284
+ return JSONResponse({"error": str(exc)}, status_code=404)
285
+ except ForgeError as exc:
286
+ return JSONResponse({"error": get_registry().mask(str(exc))}, status_code=502)
287
+ except ValueError as exc:
288
+ return JSONResponse({"error": str(exc)}, status_code=400)
289
+ return JSONResponse(detail)
290
+
291
+ async def _get_refs(self, request: Request) -> JSONResponse:
292
+ try:
293
+ rows = await asyncio.to_thread(self.session_refs, request.path_params["session_id"])
294
+ except LookupError as exc:
295
+ return JSONResponse({"error": str(exc)}, status_code=404)
296
+ return JSONResponse({"refs": rows})
297
+
298
+ async def _post_refs(self, request: Request) -> JSONResponse:
299
+ session_id = request.path_params["session_id"]
300
+ try:
301
+ body = await request.json()
302
+ except ValueError:
303
+ return JSONResponse({"error": "invalid JSON body"}, status_code=400)
304
+ if not isinstance(body, dict) or "pr" not in body:
305
+ return JSONResponse({"error": "body needs action, forge, repo and pr"}, status_code=400)
306
+ try:
307
+ self.update_refs(session_id, body.get("action"), body.get("forge"), body.get("repo"), body["pr"])
308
+ except LookupError as exc:
309
+ return JSONResponse({"error": str(exc)}, status_code=404)
310
+ except ValueError as exc:
311
+ return JSONResponse({"error": str(exc)}, status_code=400)
312
+ return await self._get_refs(request)
313
+
314
+ async def _status(self, request: Request) -> JSONResponse:
315
+ return JSONResponse({"forges": self.status()})
316
+
317
+ async def _refresh_now(self, request: Request) -> JSONResponse:
318
+ await self.refresh()
319
+ return JSONResponse({"forges": self.status()})
320
+
321
+ def _session(self, session_id: str):
322
+ session = self.session_store.resolve_live(session_id)
323
+ if session is None:
324
+ raise LookupError(f"no session {session_id!r}")
325
+ return session
326
+
327
+ def _resolve_quietly(self, spec: str) -> tuple[str, str] | None:
328
+ try:
329
+ cache, repo = self.resolve(spec)
330
+ except ValueError:
331
+ return None
332
+ return cache.config.name, repo
333
+
334
+ def session_refs(self, session_id: str) -> list[dict]:
335
+ """The session's pinned, touched and mentioned refs. Blocks on history reads and git."""
336
+ session = self._session(session_id)
337
+ stored = refs.stored_refs(session.metadata.get("refs"))
338
+ pinned_repos = {(e["forge"], e["repo"]) for e in stored if not e.get("hidden")}
339
+ bare_repo = next(iter(pinned_repos)) if len(pinned_repos) == 1 else None
340
+ chain = refs.session_chain(session.id)
341
+ # A refresh can change which repos a forge tool call in history resolves to.
342
+ refreshed = tuple(cache.last_refresh for cache in self.forges.values())
343
+ key = (refs.history_key(chain), bare_repo, refreshed)
344
+ hit = self._derived.get(session.id)
345
+ if hit is None or hit[0] != key:
346
+ events = refs.load_histories(chain)
347
+ hit = (key, refs.derive(events, self.config.forges, self._resolve_quietly, bare_repo, self._origin))
348
+ self._derived.put(session.id, hit)
349
+ derived = hit[1]
350
+ pulls = {(pr.forge, pr.repo, pr.number): asdict(pr) for cache in self.forges.values() for pr in cache.prs}
351
+ return get_registry().mask_obj(refs.merge(stored, derived, pulls))
352
+
353
+ def _origin(self, root) -> str | None:
354
+ hit = self._origins.get(root)
355
+ if hit is None or self.clock() - hit[1] > ORIGIN_TTL:
356
+ hit = (refs.git_origin(root), self.clock())
357
+ self._origins.put(root, hit)
358
+ return hit[0]
359
+
360
+ def update_refs(self, session_id: str, action: str, forge: str, repo: str, pr: int | None):
361
+ """Apply a pin, unpin, hide or unhide to the session's stored refs. Runs on the daemon loop."""
362
+ if not isinstance(forge, str) or forge not in self.forges:
363
+ raise ValueError(f"no forge named {forge!r}")
364
+ if not isinstance(repo, str) or not REPO_PATTERN.fullmatch(repo) or repo.endswith("/*"):
365
+ raise ValueError(f"repo {repo!r} must be owner/repo")
366
+ if pr is not None and (not isinstance(pr, int) or isinstance(pr, bool)):
367
+ raise ValueError("pr must be a number or null")
368
+ session = self._session(session_id)
369
+ stored = refs.apply_action(refs.stored_refs(session.metadata.get("refs")), action, forge, repo, pr)
370
+ session = self.session_store.set_metadata_bulk(session.id, {"refs": stored})
371
+ if self.event_bus is not None:
372
+ self.event_bus.emit(
373
+ "session_update", {"action": "metadata_updated", "id": session.id, "metadata": session.metadata}
374
+ )
375
+
376
+ def status(self) -> list[dict]:
377
+ now = self.clock()
378
+ return get_registry().mask_obj(
379
+ [
380
+ {
381
+ "name": cache.config.name,
382
+ "kind": cache.config.kind,
383
+ "ok": cache.last_refresh is not None and cache.error is None,
384
+ "last_refresh": _iso(cache.last_refresh),
385
+ "cache_age_seconds": None if cache.last_refresh is None else int(now - cache.last_refresh),
386
+ "error": cache.error,
387
+ "next_attempt": _iso(cache.next_attempt),
388
+ "viewer": cache.viewer,
389
+ }
390
+ for cache in self.forges.values()
391
+ ]
392
+ )
393
+
394
+
395
+ def create_adapter(*, config, runtime, session_store, identity_map):
396
+ """Adapter-plugin factory (the tsugite.adapters entry point)."""
397
+ if not config.get("forges"):
398
+ logger.info("forge plugin installed but no forges configured (add plugins.forge.forges to activate)")
399
+ return None
400
+ adapter = ForgeAdapter(ForgePluginConfig(**config), session_store=session_store)
401
+ register_link_resolver(
402
+ name="links",
403
+ hosts=sorted({refs.forge_host(forge) for forge in adapter.config.forges}),
404
+ path=previews.LINK_PATH,
405
+ resolve=adapter.link_preview,
406
+ )
407
+ return adapter
@@ -0,0 +1,25 @@
1
+ """A bounded, thread-safe least-recently-used dict."""
2
+
3
+ import threading
4
+ from collections import OrderedDict
5
+
6
+
7
+ class LruCache:
8
+ def __init__(self, max_entries: int):
9
+ self.max_entries = max_entries
10
+ self._items: OrderedDict = OrderedDict()
11
+ self._lock = threading.Lock()
12
+
13
+ def get(self, key, default=None):
14
+ with self._lock:
15
+ if key not in self._items:
16
+ return default
17
+ self._items.move_to_end(key)
18
+ return self._items[key]
19
+
20
+ def put(self, key, value) -> None:
21
+ with self._lock:
22
+ self._items[key] = value
23
+ self._items.move_to_end(key)
24
+ while len(self._items) > self.max_entries:
25
+ self._items.popitem(last=False)