tsugite-forge 0.27.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.
- tsugite_forge/__init__.py +1 -0
- tsugite_forge/adapter.py +407 -0
- tsugite_forge/cache.py +25 -0
- tsugite_forge/config.py +64 -0
- tsugite_forge/previews.py +72 -0
- tsugite_forge/providers/__init__.py +14 -0
- tsugite_forge/providers/base.py +118 -0
- tsugite_forge/providers/bitbucket.py +150 -0
- tsugite_forge/providers/forgejo.py +148 -0
- tsugite_forge/providers/github.py +192 -0
- tsugite_forge/refs.py +239 -0
- tsugite_forge/tools.py +176 -0
- tsugite_forge/ui/filters.js +48 -0
- tsugite_forge/ui/forge.css +179 -0
- tsugite_forge/ui/icons/bitbucket.svg +1 -0
- tsugite_forge/ui/icons/forgejo.svg +1 -0
- tsugite_forge/ui/icons/github.svg +1 -0
- tsugite_forge/ui/prs.html +120 -0
- tsugite_forge/ui/session.html +108 -0
- tsugite_forge/ui/surface.js +101 -0
- tsugite_forge-0.27.0.dist-info/METADATA +8 -0
- tsugite_forge-0.27.0.dist-info/RECORD +24 -0
- tsugite_forge-0.27.0.dist-info/WHEEL +4 -0
- tsugite_forge-0.27.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tsugite plugin: read pull requests from Forgejo and GitHub."""
|
tsugite_forge/adapter.py
ADDED
|
@@ -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
|
tsugite_forge/cache.py
ADDED
|
@@ -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)
|
tsugite_forge/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Configuration model for the `plugins.forge` block of daemon.yaml."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
REPO_PATTERN = re.compile(r"[\w.-]+/([\w.-]+|\*)")
|
|
9
|
+
NAME_PATTERN = r"^[A-Za-z0-9_-]+$"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Forge(BaseModel):
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
name: str = Field(pattern=NAME_PATTERN)
|
|
16
|
+
repos: list[str]
|
|
17
|
+
|
|
18
|
+
@field_validator("repos")
|
|
19
|
+
@classmethod
|
|
20
|
+
def _validate_repos(cls, repos: list[str]) -> list[str]:
|
|
21
|
+
for pattern in repos:
|
|
22
|
+
if not REPO_PATTERN.fullmatch(pattern):
|
|
23
|
+
raise ValueError(f"repo pattern {pattern!r} must be owner/repo or owner/*")
|
|
24
|
+
return repos
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ForgejoForge(_Forge):
|
|
28
|
+
kind: Literal["forgejo"]
|
|
29
|
+
url: str
|
|
30
|
+
token_secret: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GithubForge(_Forge):
|
|
34
|
+
"""Talks to GitHub through the `gh` CLI and its login."""
|
|
35
|
+
|
|
36
|
+
kind: Literal["github"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BitbucketForge(_Forge):
|
|
40
|
+
"""Bitbucket Cloud, authenticated with an Atlassian API token as Basic auth."""
|
|
41
|
+
|
|
42
|
+
kind: Literal["bitbucket"]
|
|
43
|
+
email: str
|
|
44
|
+
token_secret: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Forge = Annotated[ForgejoForge | GithubForge | BitbucketForge, Field(discriminator="kind")]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ForgePluginConfig(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
# Read by the plugin loader, which hands the factory the same block.
|
|
54
|
+
enabled: bool = True
|
|
55
|
+
poll_minutes: int = Field(5, ge=1)
|
|
56
|
+
forges: list[Forge] = []
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def _validate_unique_names(self) -> "ForgePluginConfig":
|
|
60
|
+
names = [forge.name for forge in self.forges]
|
|
61
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
62
|
+
if duplicates:
|
|
63
|
+
raise ValueError(f"forge names must be unique, repeated: {', '.join(duplicates)}")
|
|
64
|
+
return self
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Link previews for pull requests and issues, in the shape `tsugite.link_previews` resolvers return."""
|
|
2
|
+
|
|
3
|
+
from tsugite_forge.config import Forge
|
|
4
|
+
from tsugite_forge.providers import Issue, PullRequest
|
|
5
|
+
from tsugite_forge.refs import LINK_KINDS, forge_host
|
|
6
|
+
|
|
7
|
+
LINK_PATH = rf"/({'|'.join(sorted({segment for segments in LINK_KINDS.values() for segment in segments}))})/\d+/?$"
|
|
8
|
+
|
|
9
|
+
STATE_TONES = {"open": "ok", "draft": "mute", "merged": "info", "closed": "mute"}
|
|
10
|
+
CI_TONES = {"success": "ok", "failure": "err", "pending": "warn"}
|
|
11
|
+
REVIEW_TONES = {"approved": "ok", "changes_requested": "warn"}
|
|
12
|
+
KIND_NAMES = {"forgejo": "Forgejo", "github": "GitHub", "bitbucket": "Bitbucket"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _label(repo: str, number: int) -> str:
|
|
16
|
+
return f"{repo.split('/')[1]}#{number}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _state(state: str) -> dict:
|
|
20
|
+
return {"text": state, "tone": STATE_TONES[state]}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _icon(forge: Forge) -> str:
|
|
24
|
+
return f"/api/plugins/forge/ui/icons/{forge.kind}.svg"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _forge_field(forge: Forge) -> dict:
|
|
28
|
+
return {"label": "forge", "value": f"{KIND_NAMES[forge.kind]}, {forge_host(forge)}"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pr_preview(pr: PullRequest, forge: Forge) -> dict:
|
|
32
|
+
"""Shows CI on the chip while the pull request is open, else its state."""
|
|
33
|
+
state = _state("draft" if pr.draft and pr.state == "open" else pr.state)
|
|
34
|
+
ci = {"text": f"CI {pr.ci_state}", "tone": CI_TONES[pr.ci_state]} if pr.ci_state != "none" else None
|
|
35
|
+
badges = [state, *([ci] if ci else [])]
|
|
36
|
+
if pr.unresolved_threads:
|
|
37
|
+
badges.append({"text": f"{pr.unresolved_threads} unresolved", "tone": "warn"})
|
|
38
|
+
if pr.review_state != "none":
|
|
39
|
+
badges.append({"text": pr.review_state.replace("_", " "), "tone": REVIEW_TONES[pr.review_state]})
|
|
40
|
+
badges += [{"text": label, "tone": "mute"} for label in pr.labels]
|
|
41
|
+
return {
|
|
42
|
+
"label": _label(pr.repo, pr.number),
|
|
43
|
+
"title": pr.title,
|
|
44
|
+
"url": pr.url,
|
|
45
|
+
"status": ci if ci and pr.state == "open" else state,
|
|
46
|
+
"badges": badges,
|
|
47
|
+
"fields": [
|
|
48
|
+
_forge_field(forge),
|
|
49
|
+
{"label": "repo", "value": pr.repo},
|
|
50
|
+
{"label": "author", "value": pr.author},
|
|
51
|
+
{"label": "branch", "value": f"{pr.base_ref} <- {pr.head_ref}"},
|
|
52
|
+
{"label": "updated", "value": pr.updated_at},
|
|
53
|
+
],
|
|
54
|
+
"icon": _icon(forge),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def issue_preview(issue: Issue, forge: Forge) -> dict:
|
|
59
|
+
state = _state(issue.state)
|
|
60
|
+
return {
|
|
61
|
+
"label": _label(issue.repo, issue.number),
|
|
62
|
+
"title": issue.title,
|
|
63
|
+
"url": issue.url,
|
|
64
|
+
"status": state,
|
|
65
|
+
"badges": [state, *({"text": label, "tone": "mute"} for label in issue.labels)],
|
|
66
|
+
"fields": [
|
|
67
|
+
_forge_field(forge),
|
|
68
|
+
{"label": "repo", "value": issue.repo},
|
|
69
|
+
{"label": "author", "value": issue.author},
|
|
70
|
+
],
|
|
71
|
+
"icon": _icon(forge),
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Forge providers, keyed by the `kind` a forge is configured with."""
|
|
2
|
+
|
|
3
|
+
from tsugite_forge.providers.base import ForgeError, Issue, Provider, PullRequest, ReviewThreads
|
|
4
|
+
from tsugite_forge.providers.bitbucket import BitbucketProvider
|
|
5
|
+
from tsugite_forge.providers.forgejo import ForgejoProvider
|
|
6
|
+
from tsugite_forge.providers.github import GithubProvider
|
|
7
|
+
|
|
8
|
+
kinds: dict[str, type[Provider]] = {
|
|
9
|
+
"forgejo": ForgejoProvider,
|
|
10
|
+
"github": GithubProvider,
|
|
11
|
+
"bitbucket": BitbucketProvider,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
__all__ = ["ForgeError", "Issue", "Provider", "PullRequest", "ReviewThreads", "kinds"]
|