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.
Files changed (110) hide show
  1. kintsua/__init__.py +30 -0
  2. kintsua/api/__init__.py +0 -0
  3. kintsua/api/__main__.py +38 -0
  4. kintsua/api/app.py +204 -0
  5. kintsua/api/auth.py +323 -0
  6. kintsua/api/billing.py +405 -0
  7. kintsua/api/deps.py +355 -0
  8. kintsua/api/ingest.py +534 -0
  9. kintsua/api/keys.py +92 -0
  10. kintsua/api/pages.py +183 -0
  11. kintsua/api/report.py +422 -0
  12. kintsua/api/repos.py +720 -0
  13. kintsua/api/schemas.py +323 -0
  14. kintsua/api/setup.py +231 -0
  15. kintsua/api/static/style.css +79 -0
  16. kintsua/api/templates/base.html +33 -0
  17. kintsua/api/templates/error.html +9 -0
  18. kintsua/api/templates/landing.html +46 -0
  19. kintsua/api/templates/login.html +22 -0
  20. kintsua/api/templates/partials/changes.html +38 -0
  21. kintsua/api/templates/partials/deleted.html +6 -0
  22. kintsua/api/templates/partials/fix_mode.html +7 -0
  23. kintsua/api/templates/partials/key.html +7 -0
  24. kintsua/api/templates/partials/mutes.html +15 -0
  25. kintsua/api/templates/partials/notice.html +1 -0
  26. kintsua/api/templates/partials/subscribed.html +1 -0
  27. kintsua/api/templates/report.html +22 -0
  28. kintsua/api/templates/settings_list.html +42 -0
  29. kintsua/api/templates/settings_repo.html +124 -0
  30. kintsua/api/templates/setup.html +32 -0
  31. kintsua/api/templates/setup_result.html +16 -0
  32. kintsua/api/webhooks.py +817 -0
  33. kintsua/classify/__init__.py +0 -0
  34. kintsua/classify/levenshtein.py +48 -0
  35. kintsua/classify/llm_classify.py +107 -0
  36. kintsua/classify/prompts.py +174 -0
  37. kintsua/classify/rules.py +1838 -0
  38. kintsua/classify/run.py +499 -0
  39. kintsua/classify/schema.py +42 -0
  40. kintsua/cli/__init__.py +1 -0
  41. kintsua/cli/main.py +40 -0
  42. kintsua/cli/watch.py +796 -0
  43. kintsua/core/__init__.py +0 -0
  44. kintsua/core/config.py +193 -0
  45. kintsua/core/db.py +96 -0
  46. kintsua/core/events.py +85 -0
  47. kintsua/core/github.py +183 -0
  48. kintsua/core/llm.py +582 -0
  49. kintsua/core/models.py +425 -0
  50. kintsua/core/netguard.py +104 -0
  51. kintsua/core/queue.py +544 -0
  52. kintsua/core/storage.py +103 -0
  53. kintsua/core/util.py +80 -0
  54. kintsua/fixer/__init__.py +0 -0
  55. kintsua/fixer/agent.py +536 -0
  56. kintsua/fixer/patch.py +379 -0
  57. kintsua/fixer/pr.py +338 -0
  58. kintsua/fixer/prompts.py +158 -0
  59. kintsua/fixer/run.py +905 -0
  60. kintsua/fixer/sandbox.py +1165 -0
  61. kintsua/fixer/testcmd.py +412 -0
  62. kintsua/fixer/validate.py +1185 -0
  63. kintsua/indexer/__init__.py +0 -0
  64. kintsua/indexer/clone.py +205 -0
  65. kintsua/indexer/config.py +330 -0
  66. kintsua/indexer/depmap.py +372 -0
  67. kintsua/indexer/mcp_config.py +1147 -0
  68. kintsua/indexer/python_extract.py +1091 -0
  69. kintsua/indexer/resolve.py +400 -0
  70. kintsua/indexer/run.py +1018 -0
  71. kintsua/indexer/schema.py +138 -0
  72. kintsua/indexer/sdk_map.py +919 -0
  73. kintsua/indexer/treesitter.py +239 -0
  74. kintsua/indexer/ts_extract.py +807 -0
  75. kintsua/log.py +19 -0
  76. kintsua/matcher/__init__.py +0 -0
  77. kintsua/matcher/issues.py +562 -0
  78. kintsua/matcher/match.py +553 -0
  79. kintsua/matcher/mute.py +93 -0
  80. kintsua/matcher/operations.py +767 -0
  81. kintsua/matcher/runtime.py +1096 -0
  82. kintsua/report/__init__.py +0 -0
  83. kintsua/report/build.py +283 -0
  84. kintsua/report/markdown.py +291 -0
  85. kintsua/report/run.py +156 -0
  86. kintsua/sdk/__init__.py +11 -0
  87. kintsua/sdk/hooks.py +78 -0
  88. kintsua/sdk/report.py +158 -0
  89. kintsua/sdk/wrap.py +252 -0
  90. kintsua/seed/__init__.py +0 -0
  91. kintsua/seed/__main__.py +51 -0
  92. kintsua/seed/load.py +161 -0
  93. kintsua/seed/sources.yml +328 -0
  94. kintsua/watchers/__init__.py +0 -0
  95. kintsua/watchers/__main__.py +9 -0
  96. kintsua/watchers/_http.py +85 -0
  97. kintsua/watchers/_mcp_compat.py +527 -0
  98. kintsua/watchers/base.py +391 -0
  99. kintsua/watchers/changelog.py +242 -0
  100. kintsua/watchers/mcp.py +403 -0
  101. kintsua/watchers/openapi.py +1272 -0
  102. kintsua/watchers/packages.py +415 -0
  103. kintsua/watchers/run.py +895 -0
  104. kintsua/watchers/schemadiff.py +417 -0
  105. kintsua/worker.py +240 -0
  106. kintsua-0.1.0.dist-info/METADATA +735 -0
  107. kintsua-0.1.0.dist-info/RECORD +110 -0
  108. kintsua-0.1.0.dist-info/WHEEL +4 -0
  109. kintsua-0.1.0.dist-info/entry_points.txt +2 -0
  110. 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)
File without changes
@@ -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
+ ]