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.
@@ -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
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)
@@ -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"]