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.
- tsugite_forge-0.27.0/.gitignore +201 -0
- tsugite_forge-0.27.0/PKG-INFO +8 -0
- tsugite_forge-0.27.0/pyproject.toml +33 -0
- tsugite_forge-0.27.0/tsugite_forge/__init__.py +1 -0
- tsugite_forge-0.27.0/tsugite_forge/adapter.py +407 -0
- tsugite_forge-0.27.0/tsugite_forge/cache.py +25 -0
- tsugite_forge-0.27.0/tsugite_forge/config.py +64 -0
- tsugite_forge-0.27.0/tsugite_forge/previews.py +72 -0
- tsugite_forge-0.27.0/tsugite_forge/providers/__init__.py +14 -0
- tsugite_forge-0.27.0/tsugite_forge/providers/base.py +118 -0
- tsugite_forge-0.27.0/tsugite_forge/providers/bitbucket.py +150 -0
- tsugite_forge-0.27.0/tsugite_forge/providers/forgejo.py +148 -0
- tsugite_forge-0.27.0/tsugite_forge/providers/github.py +192 -0
- tsugite_forge-0.27.0/tsugite_forge/refs.py +239 -0
- tsugite_forge-0.27.0/tsugite_forge/tools.py +176 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/filters.js +48 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/forge.css +179 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/icons/bitbucket.svg +1 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/icons/forgejo.svg +1 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/icons/github.svg +1 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/prs.html +120 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/session.html +108 -0
- tsugite_forge-0.27.0/tsugite_forge/ui/surface.js +101 -0
|
@@ -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,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)
|