kintsua 0.1.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.
- kintsua/__init__.py +30 -0
- kintsua/api/__init__.py +0 -0
- kintsua/api/__main__.py +38 -0
- kintsua/api/app.py +204 -0
- kintsua/api/auth.py +323 -0
- kintsua/api/billing.py +405 -0
- kintsua/api/deps.py +355 -0
- kintsua/api/ingest.py +534 -0
- kintsua/api/keys.py +92 -0
- kintsua/api/pages.py +183 -0
- kintsua/api/report.py +422 -0
- kintsua/api/repos.py +720 -0
- kintsua/api/schemas.py +323 -0
- kintsua/api/setup.py +231 -0
- kintsua/api/static/style.css +79 -0
- kintsua/api/templates/base.html +33 -0
- kintsua/api/templates/error.html +9 -0
- kintsua/api/templates/landing.html +46 -0
- kintsua/api/templates/login.html +22 -0
- kintsua/api/templates/partials/changes.html +38 -0
- kintsua/api/templates/partials/deleted.html +6 -0
- kintsua/api/templates/partials/fix_mode.html +7 -0
- kintsua/api/templates/partials/key.html +7 -0
- kintsua/api/templates/partials/mutes.html +15 -0
- kintsua/api/templates/partials/notice.html +1 -0
- kintsua/api/templates/partials/subscribed.html +1 -0
- kintsua/api/templates/report.html +22 -0
- kintsua/api/templates/settings_list.html +42 -0
- kintsua/api/templates/settings_repo.html +124 -0
- kintsua/api/templates/setup.html +32 -0
- kintsua/api/templates/setup_result.html +16 -0
- kintsua/api/webhooks.py +817 -0
- kintsua/classify/__init__.py +0 -0
- kintsua/classify/levenshtein.py +48 -0
- kintsua/classify/llm_classify.py +107 -0
- kintsua/classify/prompts.py +174 -0
- kintsua/classify/rules.py +1838 -0
- kintsua/classify/run.py +499 -0
- kintsua/classify/schema.py +42 -0
- kintsua/cli/__init__.py +1 -0
- kintsua/cli/main.py +40 -0
- kintsua/cli/watch.py +796 -0
- kintsua/core/__init__.py +0 -0
- kintsua/core/config.py +193 -0
- kintsua/core/db.py +96 -0
- kintsua/core/events.py +85 -0
- kintsua/core/github.py +183 -0
- kintsua/core/llm.py +582 -0
- kintsua/core/models.py +425 -0
- kintsua/core/netguard.py +104 -0
- kintsua/core/queue.py +544 -0
- kintsua/core/storage.py +103 -0
- kintsua/core/util.py +80 -0
- kintsua/fixer/__init__.py +0 -0
- kintsua/fixer/agent.py +536 -0
- kintsua/fixer/patch.py +379 -0
- kintsua/fixer/pr.py +338 -0
- kintsua/fixer/prompts.py +158 -0
- kintsua/fixer/run.py +905 -0
- kintsua/fixer/sandbox.py +1165 -0
- kintsua/fixer/testcmd.py +412 -0
- kintsua/fixer/validate.py +1185 -0
- kintsua/indexer/__init__.py +0 -0
- kintsua/indexer/clone.py +205 -0
- kintsua/indexer/config.py +330 -0
- kintsua/indexer/depmap.py +372 -0
- kintsua/indexer/mcp_config.py +1147 -0
- kintsua/indexer/python_extract.py +1091 -0
- kintsua/indexer/resolve.py +400 -0
- kintsua/indexer/run.py +1018 -0
- kintsua/indexer/schema.py +138 -0
- kintsua/indexer/sdk_map.py +919 -0
- kintsua/indexer/treesitter.py +239 -0
- kintsua/indexer/ts_extract.py +807 -0
- kintsua/log.py +19 -0
- kintsua/matcher/__init__.py +0 -0
- kintsua/matcher/issues.py +562 -0
- kintsua/matcher/match.py +553 -0
- kintsua/matcher/mute.py +93 -0
- kintsua/matcher/operations.py +767 -0
- kintsua/matcher/runtime.py +1096 -0
- kintsua/report/__init__.py +0 -0
- kintsua/report/build.py +283 -0
- kintsua/report/markdown.py +291 -0
- kintsua/report/run.py +156 -0
- kintsua/sdk/__init__.py +11 -0
- kintsua/sdk/hooks.py +78 -0
- kintsua/sdk/report.py +158 -0
- kintsua/sdk/wrap.py +252 -0
- kintsua/seed/__init__.py +0 -0
- kintsua/seed/__main__.py +51 -0
- kintsua/seed/load.py +161 -0
- kintsua/seed/sources.yml +328 -0
- kintsua/watchers/__init__.py +0 -0
- kintsua/watchers/__main__.py +9 -0
- kintsua/watchers/_http.py +85 -0
- kintsua/watchers/_mcp_compat.py +527 -0
- kintsua/watchers/base.py +391 -0
- kintsua/watchers/changelog.py +242 -0
- kintsua/watchers/mcp.py +403 -0
- kintsua/watchers/openapi.py +1272 -0
- kintsua/watchers/packages.py +415 -0
- kintsua/watchers/run.py +895 -0
- kintsua/watchers/schemadiff.py +417 -0
- kintsua/worker.py +240 -0
- kintsua-0.1.0.dist-info/METADATA +735 -0
- kintsua-0.1.0.dist-info/RECORD +110 -0
- kintsua-0.1.0.dist-info/WHEEL +4 -0
- kintsua-0.1.0.dist-info/entry_points.txt +2 -0
- kintsua-0.1.0.dist-info/licenses/LICENSE +202 -0
kintsua/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Kintsua: self-maintaining integrations for AI agents.
|
|
2
|
+
|
|
3
|
+
``import kintsua`` gives you the runtime SDK (``wrap_mcp``, ``wrap_http``, ``hook_*``);
|
|
4
|
+
``kintsua watch`` is the open-source CLI. The hosted service lives in the other
|
|
5
|
+
subpackages and needs ``pip install "kintsua[server]"``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"wrap_mcp",
|
|
15
|
+
"wrap_http",
|
|
16
|
+
"hook_langchain",
|
|
17
|
+
"hook_openai_agents",
|
|
18
|
+
"hook_google_adk",
|
|
19
|
+
"configure",
|
|
20
|
+
"flush",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str):
|
|
25
|
+
# Lazy so importing the package never pulls the SDK's reporter thread unless used.
|
|
26
|
+
if name in __all__ and name != "__version__":
|
|
27
|
+
from kintsua import sdk
|
|
28
|
+
|
|
29
|
+
return getattr(sdk, name)
|
|
30
|
+
raise AttributeError(name)
|
kintsua/api/__init__.py
ADDED
|
File without changes
|
kintsua/api/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""``python -m kintsua.api``: serve the API with uvicorn on ``0.0.0.0:$PORT`` (default 8000).
|
|
2
|
+
|
|
3
|
+
Exits with status 2 without serving when ``kintsua.api.app.startup_problem()`` names a
|
|
4
|
+
problem (prod without S3-compatible storage or a shared ``DATA_DIR``), like the worker."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from kintsua.api.app import startup_problem
|
|
13
|
+
from kintsua.core.config import get_settings
|
|
14
|
+
from kintsua.log import setup_logging
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
setup_logging()
|
|
19
|
+
settings = get_settings()
|
|
20
|
+
if startup_problem(settings):
|
|
21
|
+
return 2
|
|
22
|
+
port = int(os.environ.get("PORT", "8000"))
|
|
23
|
+
host = os.environ.get("HOST", "0.0.0.0")
|
|
24
|
+
uvicorn.run(
|
|
25
|
+
"kintsua.api.app:create_app",
|
|
26
|
+
factory=True,
|
|
27
|
+
host=host,
|
|
28
|
+
port=port,
|
|
29
|
+
log_level=settings.log_level.lower(),
|
|
30
|
+
proxy_headers=True,
|
|
31
|
+
forwarded_allow_ips="*",
|
|
32
|
+
access_log=False,
|
|
33
|
+
)
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__": # pragma: no cover
|
|
38
|
+
raise SystemExit(main())
|
kintsua/api/app.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The FastAPI application: ``create_app()`` mounts every router, the static files and the
|
|
2
|
+
Jinja2 templates, adds ``/healthz`` (pings Postgres), structured error handlers that never
|
|
3
|
+
leak a stack trace, and request logging with a request id.
|
|
4
|
+
|
|
5
|
+
Like the worker, the app refuses to start when ``kintsua.core.storage.storage_problem()``
|
|
6
|
+
names a problem (prod without S3 or a shared ``DATA_DIR``): ``create_app()`` logs it and
|
|
7
|
+
raises ``StorageNotConfigured``, and ``python -m kintsua.api`` exits with status 2.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from fastapi.exceptions import RequestValidationError
|
|
19
|
+
from fastapi.responses import JSONResponse, Response
|
|
20
|
+
from fastapi.staticfiles import StaticFiles
|
|
21
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
22
|
+
|
|
23
|
+
from kintsua.api import auth, billing, ingest, keys, pages, report, repos, setup, webhooks
|
|
24
|
+
from kintsua.api.deps import STATIC_DIR, is_htmx, render
|
|
25
|
+
from kintsua.core.config import Settings, get_settings
|
|
26
|
+
from kintsua.core.storage import StorageNotConfigured, storage_problem
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
API_PREFIXES = ("/v1", "/webhooks", "/healthz", "/api")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _wants_json(request: Request) -> bool:
|
|
34
|
+
if is_htmx(request):
|
|
35
|
+
return False
|
|
36
|
+
if request.url.path.startswith(API_PREFIXES):
|
|
37
|
+
return True
|
|
38
|
+
accept = request.headers.get("accept", "")
|
|
39
|
+
if "application/json" in accept and "text/html" not in accept:
|
|
40
|
+
return True
|
|
41
|
+
# a client that sends JSON is a JSON client, whatever it accepts
|
|
42
|
+
return (
|
|
43
|
+
"application/json" in request.headers.get("content-type", "") and "text/html" not in accept
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _error_response(request: Request, status: int, title: str, detail: Any) -> Response:
|
|
48
|
+
message = detail if isinstance(detail, str) else title
|
|
49
|
+
if _wants_json(request):
|
|
50
|
+
body: dict[str, Any] = {"error": message, "status": status}
|
|
51
|
+
if not isinstance(detail, str) and detail is not None:
|
|
52
|
+
body["details"] = detail
|
|
53
|
+
return JSONResponse(body, status_code=status)
|
|
54
|
+
if is_htmx(request):
|
|
55
|
+
return render(
|
|
56
|
+
request, "partials/notice.html", status_code=status, kind="error", message=message
|
|
57
|
+
)
|
|
58
|
+
return render(request, "error.html", status_code=status, title=title, message=message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validation_details(exc: RequestValidationError) -> list[dict[str, Any]]:
|
|
62
|
+
out: list[dict[str, Any]] = []
|
|
63
|
+
for err in exc.errors():
|
|
64
|
+
if not isinstance(err, dict):
|
|
65
|
+
out.append({"msg": str(err)})
|
|
66
|
+
continue
|
|
67
|
+
out.append(
|
|
68
|
+
{
|
|
69
|
+
"loc": [str(x) for x in err.get("loc", ())],
|
|
70
|
+
"msg": str(err.get("msg", "invalid value")),
|
|
71
|
+
"type": str(err.get("type", "")),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def startup_problem(settings: Settings | None = None) -> str | None:
|
|
78
|
+
"""Why the API must not start with this configuration (logged as an error), else None.
|
|
79
|
+
Today: the large-document storage check the worker also runs."""
|
|
80
|
+
problem = storage_problem(settings or get_settings())
|
|
81
|
+
if problem:
|
|
82
|
+
log.error("refusing to start: %s", problem)
|
|
83
|
+
return problem
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_app() -> FastAPI:
|
|
87
|
+
settings = get_settings()
|
|
88
|
+
problem = startup_problem(settings)
|
|
89
|
+
if problem:
|
|
90
|
+
raise StorageNotConfigured(problem)
|
|
91
|
+
app = FastAPI(
|
|
92
|
+
title="Kintsua",
|
|
93
|
+
version="0.1.0",
|
|
94
|
+
description="Self-maintaining integrations for AI agents.",
|
|
95
|
+
docs_url="/docs" if settings.env != "prod" else None,
|
|
96
|
+
redoc_url=None,
|
|
97
|
+
openapi_url="/openapi.json" if settings.env != "prod" else None,
|
|
98
|
+
)
|
|
99
|
+
app.state.settings = settings
|
|
100
|
+
|
|
101
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
102
|
+
for r in (
|
|
103
|
+
webhooks.router,
|
|
104
|
+
ingest.router,
|
|
105
|
+
repos.router,
|
|
106
|
+
keys.router,
|
|
107
|
+
billing.router,
|
|
108
|
+
report.router,
|
|
109
|
+
auth.router,
|
|
110
|
+
setup.router,
|
|
111
|
+
pages.router,
|
|
112
|
+
):
|
|
113
|
+
app.include_router(r)
|
|
114
|
+
|
|
115
|
+
@app.get("/healthz", tags=["ops"])
|
|
116
|
+
def healthz() -> Response:
|
|
117
|
+
from kintsua.core.db import ping
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
ping()
|
|
121
|
+
except Exception as e: # noqa: BLE001 - report, do not raise
|
|
122
|
+
log.warning("healthz: database ping failed: %s", type(e).__name__)
|
|
123
|
+
return JSONResponse({"ok": False, "db": False, "env": settings.env}, status_code=503)
|
|
124
|
+
return JSONResponse({"ok": True, "db": True, "env": settings.env})
|
|
125
|
+
|
|
126
|
+
@app.exception_handler(StarletteHTTPException)
|
|
127
|
+
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
|
|
128
|
+
resp = _error_response(request, exc.status_code, _title_for(exc.status_code), exc.detail)
|
|
129
|
+
for k, v in (getattr(exc, "headers", None) or {}).items():
|
|
130
|
+
resp.headers[k] = v
|
|
131
|
+
return resp
|
|
132
|
+
|
|
133
|
+
@app.exception_handler(RequestValidationError)
|
|
134
|
+
async def _validation_error(request: Request, exc: RequestValidationError) -> Response:
|
|
135
|
+
details = _validation_details(exc)
|
|
136
|
+
if _wants_json(request):
|
|
137
|
+
return JSONResponse(
|
|
138
|
+
{"error": "validation failed", "status": 422, "details": details}, status_code=422
|
|
139
|
+
)
|
|
140
|
+
message = "; ".join(
|
|
141
|
+
f"{'.'.join(d.get('loc', [])) or 'body'}: {d.get('msg')}" for d in details
|
|
142
|
+
)
|
|
143
|
+
return _error_response(request, 422, "Invalid request", message or "validation failed")
|
|
144
|
+
|
|
145
|
+
@app.exception_handler(Exception)
|
|
146
|
+
async def _unhandled(request: Request, exc: Exception) -> Response:
|
|
147
|
+
rid = getattr(request.state, "request_id", "-")
|
|
148
|
+
log.exception(
|
|
149
|
+
"unhandled error on %s %s (request %s)", request.method, request.url.path, rid
|
|
150
|
+
)
|
|
151
|
+
resp = _error_response(request, 500, "Internal error", f"internal error (request {rid})")
|
|
152
|
+
resp.headers["X-Request-Id"] = str(rid)
|
|
153
|
+
return resp
|
|
154
|
+
|
|
155
|
+
@app.middleware("http")
|
|
156
|
+
async def _log_requests(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
157
|
+
rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
|
|
158
|
+
request.state.request_id = rid
|
|
159
|
+
t0 = time.perf_counter()
|
|
160
|
+
try:
|
|
161
|
+
response = await call_next(request)
|
|
162
|
+
except Exception:
|
|
163
|
+
log.info(
|
|
164
|
+
"%s %s -> 500 %.1fms rid=%s",
|
|
165
|
+
request.method,
|
|
166
|
+
request.url.path,
|
|
167
|
+
(time.perf_counter() - t0) * 1000,
|
|
168
|
+
rid,
|
|
169
|
+
)
|
|
170
|
+
raise
|
|
171
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
172
|
+
response.headers.setdefault("X-Request-Id", rid)
|
|
173
|
+
if request.url.path != "/healthz":
|
|
174
|
+
log.info(
|
|
175
|
+
"%s %s -> %s %.1fms rid=%s",
|
|
176
|
+
request.method,
|
|
177
|
+
request.url.path,
|
|
178
|
+
response.status_code,
|
|
179
|
+
ms,
|
|
180
|
+
rid,
|
|
181
|
+
)
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
return app
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _title_for(status: int) -> str:
|
|
188
|
+
return {
|
|
189
|
+
400: "Bad request",
|
|
190
|
+
401: "Sign in required",
|
|
191
|
+
403: "Forbidden",
|
|
192
|
+
404: "Not found",
|
|
193
|
+
405: "Method not allowed",
|
|
194
|
+
409: "Conflict",
|
|
195
|
+
413: "Payload too large",
|
|
196
|
+
422: "Invalid request",
|
|
197
|
+
429: "Too many requests",
|
|
198
|
+
500: "Internal error",
|
|
199
|
+
502: "Upstream error",
|
|
200
|
+
503: "Service unavailable",
|
|
201
|
+
}.get(status, f"HTTP {status}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = ["create_app", "startup_problem"]
|
kintsua/api/auth.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Sign in with GitHub through the App's OAuth flow.
|
|
2
|
+
|
|
3
|
+
``GET /login`` sends the browser to GitHub with a signed, short-lived ``state`` cookie;
|
|
4
|
+
``GET /auth/callback`` exchanges the code for a user token, reads the user and the
|
|
5
|
+
installations that user can see (``GET /user/installations``) and sets the session cookie
|
|
6
|
+
(HttpOnly, SameSite=Lax, Secure when ``base_url`` is https). ``POST /logout`` clears it.
|
|
7
|
+
|
|
8
|
+
When OAuth is not configured ``/login`` explains how to configure it. In ``ENV=dev`` with
|
|
9
|
+
``DEV_LOGIN_INSTALLATION_ID`` set, ``POST /auth/dev`` mints a session for that installation
|
|
10
|
+
so local work is possible without an OAuth app.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hmac
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from fastapi import APIRouter, Request
|
|
23
|
+
from fastapi.responses import RedirectResponse, Response
|
|
24
|
+
from itsdangerous import BadSignature, SignatureExpired
|
|
25
|
+
|
|
26
|
+
from kintsua.api.deps import (
|
|
27
|
+
OAUTH_SALT,
|
|
28
|
+
OAUTH_STATE_COOKIE,
|
|
29
|
+
clear_session_cookie,
|
|
30
|
+
cookie_secure,
|
|
31
|
+
render,
|
|
32
|
+
serializer,
|
|
33
|
+
set_session_cookie,
|
|
34
|
+
)
|
|
35
|
+
from kintsua.api.schemas import SessionUser
|
|
36
|
+
from kintsua.core.config import Settings, get_settings
|
|
37
|
+
from kintsua.core.util import new_token
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
router = APIRouter(tags=["auth"])
|
|
42
|
+
|
|
43
|
+
OAUTH_STATE_MAX_AGE_S = 600
|
|
44
|
+
HTTP_TIMEOUT_S = 20.0
|
|
45
|
+
MAX_INSTALLATION_PAGES = 10
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OAuthError(Exception):
|
|
49
|
+
"""GitHub refused or returned something we could not use."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def oauth_configured(settings: Settings | None = None) -> bool:
|
|
53
|
+
s = settings or get_settings()
|
|
54
|
+
return bool(s.github_oauth_client_id and s.github_oauth_client_secret)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dev_login_installation_id(settings: Settings | None = None) -> int | None:
|
|
58
|
+
s = settings or get_settings()
|
|
59
|
+
if s.env != "dev":
|
|
60
|
+
return None
|
|
61
|
+
raw = os.environ.get("DEV_LOGIN_INSTALLATION_ID", "").strip()
|
|
62
|
+
if not raw:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return int(raw)
|
|
66
|
+
except ValueError:
|
|
67
|
+
log.warning("DEV_LOGIN_INSTALLATION_ID=%r is not an integer", raw)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def github_web_url(settings: Settings | None = None) -> str:
|
|
72
|
+
"""``https://github.com`` for github.com; the GHES host for ``.../api/v3`` API URLs."""
|
|
73
|
+
s = settings or get_settings()
|
|
74
|
+
api = s.github_api_url.rstrip("/")
|
|
75
|
+
if api == "https://api.github.com":
|
|
76
|
+
return "https://github.com"
|
|
77
|
+
if api.endswith("/api/v3"):
|
|
78
|
+
return api[: -len("/api/v3")]
|
|
79
|
+
return api
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def safe_next(value: str | None, default: str = "/settings") -> str:
|
|
83
|
+
"""Only same-site paths are allowed as a post-login redirect (no open redirects)."""
|
|
84
|
+
if not value or not value.startswith("/") or value.startswith("//") or "\\" in value:
|
|
85
|
+
return default
|
|
86
|
+
return value[:500]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def redirect_uri(settings: Settings | None = None) -> str:
|
|
90
|
+
s = settings or get_settings()
|
|
91
|
+
return f"{s.base_url.rstrip('/')}/auth/callback"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------------------
|
|
95
|
+
# GitHub calls (plain httpx so tests can mock them with respx)
|
|
96
|
+
# --------------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def exchange_code(settings: Settings, code: str) -> str:
|
|
100
|
+
try:
|
|
101
|
+
with httpx.Client(timeout=HTTP_TIMEOUT_S) as client:
|
|
102
|
+
r = client.post(
|
|
103
|
+
f"{github_web_url(settings)}/login/oauth/access_token",
|
|
104
|
+
data={
|
|
105
|
+
"client_id": settings.github_oauth_client_id,
|
|
106
|
+
"client_secret": settings.github_oauth_client_secret,
|
|
107
|
+
"code": code,
|
|
108
|
+
"redirect_uri": redirect_uri(settings),
|
|
109
|
+
},
|
|
110
|
+
headers={"Accept": "application/json"},
|
|
111
|
+
)
|
|
112
|
+
except httpx.HTTPError as e:
|
|
113
|
+
raise OAuthError(f"could not reach GitHub: {type(e).__name__}") from e
|
|
114
|
+
if r.status_code != 200:
|
|
115
|
+
raise OAuthError(f"token exchange returned HTTP {r.status_code}")
|
|
116
|
+
try:
|
|
117
|
+
data = r.json()
|
|
118
|
+
except ValueError:
|
|
119
|
+
raise OAuthError("token exchange returned a non-JSON body")
|
|
120
|
+
if not isinstance(data, dict) or data.get("error"):
|
|
121
|
+
desc = data.get("error_description") if isinstance(data, dict) else None
|
|
122
|
+
raise OAuthError(str(desc or (data.get("error") if isinstance(data, dict) else data)))
|
|
123
|
+
token = data.get("access_token")
|
|
124
|
+
if not token:
|
|
125
|
+
raise OAuthError("no access_token in GitHub's response")
|
|
126
|
+
return str(token)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _api_get(settings: Settings, token: str, path: str, params: dict[str, Any] | None = None):
|
|
130
|
+
try:
|
|
131
|
+
with httpx.Client(timeout=HTTP_TIMEOUT_S) as client:
|
|
132
|
+
r = client.get(
|
|
133
|
+
f"{settings.github_api_url.rstrip('/')}{path}",
|
|
134
|
+
params=params,
|
|
135
|
+
headers={
|
|
136
|
+
"Authorization": f"Bearer {token}",
|
|
137
|
+
"Accept": "application/vnd.github+json",
|
|
138
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
139
|
+
"User-Agent": settings.user_agent,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
except httpx.HTTPError as e:
|
|
143
|
+
raise OAuthError(f"could not reach GitHub: {type(e).__name__}") from e
|
|
144
|
+
if r.status_code != 200:
|
|
145
|
+
raise OAuthError(f"GET {path} returned HTTP {r.status_code}")
|
|
146
|
+
try:
|
|
147
|
+
return r.json()
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise OAuthError(f"GET {path} returned a non-JSON body")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fetch_user(settings: Settings, token: str) -> dict[str, Any]:
|
|
153
|
+
data = _api_get(settings, token, "/user")
|
|
154
|
+
if not isinstance(data, dict) or not data.get("login"):
|
|
155
|
+
raise OAuthError("GitHub did not return a user")
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fetch_installation_ids(settings: Settings, token: str) -> list[int]:
|
|
160
|
+
ids: list[int] = []
|
|
161
|
+
for page in range(1, MAX_INSTALLATION_PAGES + 1):
|
|
162
|
+
data = _api_get(
|
|
163
|
+
settings, token, "/user/installations", params={"per_page": 100, "page": page}
|
|
164
|
+
)
|
|
165
|
+
rows = data.get("installations") if isinstance(data, dict) else None
|
|
166
|
+
if not isinstance(rows, list):
|
|
167
|
+
break
|
|
168
|
+
for inst in rows:
|
|
169
|
+
if isinstance(inst, dict) and inst.get("id") is not None:
|
|
170
|
+
try:
|
|
171
|
+
ids.append(int(inst["id"]))
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
continue
|
|
174
|
+
if len(rows) < 100:
|
|
175
|
+
break
|
|
176
|
+
return sorted(set(ids))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------------------
|
|
180
|
+
# Routes
|
|
181
|
+
# --------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get("/login")
|
|
185
|
+
def login(request: Request, next: str | None = None) -> Response:
|
|
186
|
+
s = get_settings()
|
|
187
|
+
target = safe_next(next)
|
|
188
|
+
if not oauth_configured(s):
|
|
189
|
+
return render(
|
|
190
|
+
request,
|
|
191
|
+
"login.html",
|
|
192
|
+
configured=False,
|
|
193
|
+
dev_login=dev_login_installation_id(s) is not None,
|
|
194
|
+
next=target,
|
|
195
|
+
redirect_uri=redirect_uri(s),
|
|
196
|
+
)
|
|
197
|
+
state = serializer(OAUTH_SALT).dumps({"n": new_token(16), "next": target})
|
|
198
|
+
params = {
|
|
199
|
+
"client_id": s.github_oauth_client_id,
|
|
200
|
+
"redirect_uri": redirect_uri(s),
|
|
201
|
+
"state": state,
|
|
202
|
+
}
|
|
203
|
+
resp = RedirectResponse(
|
|
204
|
+
url=f"{github_web_url(s)}/login/oauth/authorize?{urlencode(params)}", status_code=302
|
|
205
|
+
)
|
|
206
|
+
resp.set_cookie(
|
|
207
|
+
OAUTH_STATE_COOKIE,
|
|
208
|
+
state,
|
|
209
|
+
max_age=OAUTH_STATE_MAX_AGE_S,
|
|
210
|
+
httponly=True,
|
|
211
|
+
samesite="lax",
|
|
212
|
+
secure=cookie_secure(),
|
|
213
|
+
path="/",
|
|
214
|
+
)
|
|
215
|
+
return resp
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get("/auth/callback")
|
|
219
|
+
def auth_callback(
|
|
220
|
+
request: Request,
|
|
221
|
+
code: str | None = None,
|
|
222
|
+
state: str | None = None,
|
|
223
|
+
error: str | None = None,
|
|
224
|
+
error_description: str | None = None,
|
|
225
|
+
) -> Response:
|
|
226
|
+
s = get_settings()
|
|
227
|
+
if not oauth_configured(s):
|
|
228
|
+
return render(
|
|
229
|
+
request,
|
|
230
|
+
"error.html",
|
|
231
|
+
status_code=404,
|
|
232
|
+
title="Sign-in is not configured",
|
|
233
|
+
message="GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET are not set.",
|
|
234
|
+
)
|
|
235
|
+
if error:
|
|
236
|
+
return render(
|
|
237
|
+
request,
|
|
238
|
+
"error.html",
|
|
239
|
+
status_code=400,
|
|
240
|
+
title="GitHub refused the sign-in",
|
|
241
|
+
message=error_description or error,
|
|
242
|
+
)
|
|
243
|
+
cookie_state = request.cookies.get(OAUTH_STATE_COOKIE) or ""
|
|
244
|
+
if not code or not state or not cookie_state or not hmac.compare_digest(state, cookie_state):
|
|
245
|
+
return render(
|
|
246
|
+
request,
|
|
247
|
+
"error.html",
|
|
248
|
+
status_code=400,
|
|
249
|
+
title="Sign-in state mismatch",
|
|
250
|
+
message="The sign-in link expired or was opened in another browser. Try again.",
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
data = serializer(OAUTH_SALT).loads(state, max_age=OAUTH_STATE_MAX_AGE_S)
|
|
254
|
+
except (BadSignature, SignatureExpired):
|
|
255
|
+
return render(
|
|
256
|
+
request,
|
|
257
|
+
"error.html",
|
|
258
|
+
status_code=400,
|
|
259
|
+
title="Sign-in state expired",
|
|
260
|
+
message="Start again from /login.",
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
token = exchange_code(s, code)
|
|
264
|
+
gh_user = fetch_user(s, token)
|
|
265
|
+
installation_ids = fetch_installation_ids(s, token)
|
|
266
|
+
except OAuthError as e:
|
|
267
|
+
log.warning("GitHub OAuth failed: %s", e)
|
|
268
|
+
return render(
|
|
269
|
+
request,
|
|
270
|
+
"error.html",
|
|
271
|
+
status_code=502,
|
|
272
|
+
title="GitHub sign-in failed",
|
|
273
|
+
message=str(e),
|
|
274
|
+
)
|
|
275
|
+
user = SessionUser(
|
|
276
|
+
login=str(gh_user["login"]),
|
|
277
|
+
id=int(gh_user.get("id") or 0),
|
|
278
|
+
installation_ids=installation_ids,
|
|
279
|
+
)
|
|
280
|
+
resp = RedirectResponse(url=safe_next(data.get("next")), status_code=303)
|
|
281
|
+
set_session_cookie(resp, user)
|
|
282
|
+
resp.delete_cookie(OAUTH_STATE_COOKIE, path="/")
|
|
283
|
+
log.info("user %s signed in with %d installation(s)", user.login, len(installation_ids))
|
|
284
|
+
return resp
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.post("/logout")
|
|
288
|
+
def logout() -> Response:
|
|
289
|
+
resp = RedirectResponse(url="/", status_code=303)
|
|
290
|
+
clear_session_cookie(resp)
|
|
291
|
+
return resp
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@router.api_route("/auth/dev", methods=["GET", "POST"])
|
|
295
|
+
def dev_login(request: Request, next: str | None = None) -> Response:
|
|
296
|
+
"""Local development only: ``ENV=dev`` plus ``DEV_LOGIN_INSTALLATION_ID``."""
|
|
297
|
+
inst = dev_login_installation_id()
|
|
298
|
+
if inst is None:
|
|
299
|
+
return render(
|
|
300
|
+
request,
|
|
301
|
+
"error.html",
|
|
302
|
+
status_code=404,
|
|
303
|
+
title="Not found",
|
|
304
|
+
message="Dev login is only available with ENV=dev and DEV_LOGIN_INSTALLATION_ID set.",
|
|
305
|
+
)
|
|
306
|
+
user = SessionUser(login=os.environ.get("DEV_LOGIN_USER", "dev"), id=0, installation_ids=[inst])
|
|
307
|
+
resp = RedirectResponse(url=safe_next(next), status_code=303)
|
|
308
|
+
set_session_cookie(resp, user)
|
|
309
|
+
return resp
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
__all__ = [
|
|
313
|
+
"OAuthError",
|
|
314
|
+
"dev_login_installation_id",
|
|
315
|
+
"exchange_code",
|
|
316
|
+
"fetch_installation_ids",
|
|
317
|
+
"fetch_user",
|
|
318
|
+
"github_web_url",
|
|
319
|
+
"oauth_configured",
|
|
320
|
+
"redirect_uri",
|
|
321
|
+
"router",
|
|
322
|
+
"safe_next",
|
|
323
|
+
]
|