mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
* feat: open Slack-triggered PRs as the triggering user Route the Slack per-user GitHub token through the dashboard OAuth store (the backend the self-service link prompt populates) and block runs that lack a valid user token, prompting the user to (re-)link. Per-user OAuth now wins over bot-token-only mode for mapped Slack/dashboard users. Flip commit/PR authorship across all sources: the triggering user is the commit author (via repo-local git identity using their resolvable GitHub noreply email) and open-swe[bot] is the Co-authored-by collaborator. * fix: address PR review — shell-escape commit identity, fix token cache impersonation - Shell-escape the triggering user's name/email with shlex.quote before embedding them in the repo-setup `git config` command, so a name like O'Connor (or a crafted one) can't break or inject into the command. - Stop consulting the shared thread-metadata token cache in _resolve_dashboard_user_token. Slack thread ids are shared across the conversation, so a cached token from a prior triggering user could be returned for the current github_login. Always resolve by login from the dashboard OAuth store instead. * feat: dashboard self-service user mapping + UI cleanup - Add session-scoped GET/PUT /dashboard/api/my-mapping so users can set their own work email / Slack member ID (keyed by their GitHub login, source=self). - Slack account-link prompt now redirects to Profile Settings after auth. - Rename "My Settings" -> "Profile Settings" and "Cloud Agents" -> "Open SWE Agent"; remove the Integrations tab/section (folded out, low value for now) and redirect /integrations to Profile Settings. - Add a "User mapping" section to Profile Settings (work email used by Slack and Linear, optional Slack member ID). - Make dashboard auth cookies scheme-aware: Secure;SameSite=None over HTTPS, non-Secure;SameSite=Lax over http://localhost so local login works. * feat: self-service Slack account linking via Sign in with Slack (OIDC) Replace the spoofable manual work-email/Slack-ID form with a verified "Sign in with Slack" flow so a logged-in GitHub user can only ever link their own Slack identity. - New agent/dashboard/slack_oauth.py: OIDC authorize URL, code exchange, userInfo identity parse, optional workspace gate, configured check. - routes.py: session-gated GET /slack/login and /slack/callback that upsert the mapping from Slack-verified user_id + email (source=slack_oauth). Remove the spoofable PUT /my-mapping; expose slack_oauth_enabled on /me. - UI: drop the editable inputs; add a Connect Slack button + status to the User mapping section. Admin-managed mappings are unaffected and still resolve at trigger time.
836 lines
26 KiB
Python
836 lines
26 KiB
Python
"""FastAPI router for the dashboard backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from .admin import is_admin
|
|
from .analyzer_cron import remove_continual_cron
|
|
from .enabled_repos import (
|
|
list_enabled_review_repos,
|
|
set_review_repo_enabled,
|
|
)
|
|
from .oauth import (
|
|
COOKIE_NAME,
|
|
SESSION_TTL_SECONDS,
|
|
STATE_COOKIE_NAME,
|
|
STATE_TTL_SECONDS,
|
|
decode_account_link,
|
|
decode_state,
|
|
enforce_org_login_gate,
|
|
exchange_code,
|
|
fetch_github_user,
|
|
hash_state_nonce,
|
|
issue_session,
|
|
issue_state,
|
|
new_state_nonce,
|
|
require_session,
|
|
sanitize_redirect_to,
|
|
)
|
|
from .options import SUPPORTED_MODELS
|
|
from .profiles import (
|
|
ProfileUpdate,
|
|
get_profile,
|
|
get_valid_access_token,
|
|
upsert_access_token_from_github_response,
|
|
upsert_profile,
|
|
)
|
|
from .review_style_jobs import (
|
|
cancel_review_style_analysis,
|
|
start_bootstrap_analysis,
|
|
sync_review_style_run_status,
|
|
)
|
|
from .review_styles import (
|
|
ReviewStyleCreate,
|
|
ReviewStylePromptUpdate,
|
|
create_review_style,
|
|
delete_review_style,
|
|
get_review_style,
|
|
list_review_styles,
|
|
normalize_repo_full_name,
|
|
set_custom_prompt,
|
|
)
|
|
from .slack_oauth import (
|
|
SLACK_STATE_COOKIE_NAME,
|
|
build_authorize_url,
|
|
exchange_slack_code,
|
|
fetch_slack_identity,
|
|
slack_oauth_configured,
|
|
verify_team,
|
|
)
|
|
from .team_settings import (
|
|
TeamSettingsUpdate,
|
|
get_team_settings,
|
|
upsert_team_settings,
|
|
)
|
|
from .thread_api import (
|
|
ThreadCreateBody,
|
|
ThreadMessageBody,
|
|
cancel_dashboard_thread,
|
|
create_dashboard_thread,
|
|
delete_dashboard_thread,
|
|
get_dashboard_thread,
|
|
list_dashboard_threads,
|
|
send_dashboard_message,
|
|
stream_dashboard_thread,
|
|
)
|
|
from .user_mappings import (
|
|
delete_mapping,
|
|
get_mapping,
|
|
list_mappings,
|
|
upsert_mapping,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
|
|
|
|
|
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
|
|
if not is_admin(session.get("email")):
|
|
raise HTTPException(403, "admin only")
|
|
return session
|
|
|
|
|
|
_SESSION_DEP = Depends(require_session)
|
|
|
|
|
|
def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return _require_admin(session)
|
|
|
|
|
|
_ADMIN_DEP = Depends(_admin_session)
|
|
|
|
|
|
def _api_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_API_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _frontend_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _cookie_security() -> tuple[bool, str]:
|
|
"""Cookie ``secure``/``samesite`` flags derived from the API scheme.
|
|
|
|
Production serves the API over HTTPS and the dashboard is a separate
|
|
(cross-site) origin, so the session cookie must be ``Secure; SameSite=None``.
|
|
Local dev runs over ``http://localhost`` where ``Secure`` cookies are
|
|
rejected and the frontend/API are same-site, so fall back to
|
|
``SameSite=Lax`` without ``Secure``.
|
|
"""
|
|
if os.environ.get("DASHBOARD_API_BASE_URL", "").startswith("https://"):
|
|
return True, "none"
|
|
return False, "lax"
|
|
|
|
|
|
def _set_session_cookie(response: Response, jwt_token: str) -> None:
|
|
secure, samesite = _cookie_security()
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=jwt_token,
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=secure,
|
|
samesite=samesite,
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _set_state_cookie(response: Response, nonce: str) -> None:
|
|
# SameSite=Lax so GitHub's top-level redirect back to /auth/callback
|
|
# still presents this cookie; the cookie is single-purpose and lives
|
|
# only for the duration of one OAuth round-trip.
|
|
secure, _ = _cookie_security()
|
|
response.set_cookie(
|
|
key=STATE_COOKIE_NAME,
|
|
value=nonce,
|
|
max_age=STATE_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=secure,
|
|
samesite="lax",
|
|
path="/dashboard/api/auth",
|
|
)
|
|
|
|
|
|
def _clear_state_cookie(response: Response) -> None:
|
|
secure, _ = _cookie_security()
|
|
response.delete_cookie(
|
|
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=secure
|
|
)
|
|
|
|
|
|
def _set_slack_state_cookie(response: Response, nonce: str) -> None:
|
|
secure, _ = _cookie_security()
|
|
response.set_cookie(
|
|
key=SLACK_STATE_COOKIE_NAME,
|
|
value=nonce,
|
|
max_age=STATE_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=secure,
|
|
samesite="lax",
|
|
path="/dashboard/api/slack",
|
|
)
|
|
|
|
|
|
def _clear_slack_state_cookie(response: Response) -> None:
|
|
secure, _ = _cookie_security()
|
|
response.delete_cookie(
|
|
SLACK_STATE_COOKIE_NAME, path="/dashboard/api/slack", samesite="lax", secure=secure
|
|
)
|
|
|
|
|
|
@router.get("/auth/login")
|
|
async def auth_login(
|
|
request: Request,
|
|
redirect_to: str | None = None,
|
|
link: str | None = None,
|
|
) -> RedirectResponse:
|
|
client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
|
|
if not client_id:
|
|
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
|
|
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url()
|
|
|
|
# Only carry a structurally valid account-link token onward.
|
|
link_token = link if (link and decode_account_link(link)) else None
|
|
|
|
nonce = new_state_nonce()
|
|
state = issue_state(
|
|
redirect_to=safe_redirect,
|
|
nonce_hash=hash_state_nonce(nonce),
|
|
link=link_token,
|
|
)
|
|
redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
|
|
url = (
|
|
"https://github.com/login/oauth/authorize"
|
|
f"?client_id={client_id}"
|
|
f"&redirect_uri={redirect_uri}"
|
|
f"&state={state}"
|
|
)
|
|
response = RedirectResponse(url, status_code=302)
|
|
_set_state_cookie(response, nonce)
|
|
return response
|
|
|
|
|
|
@router.get("/auth/callback")
|
|
async def auth_callback(request: Request, code: str, state: str) -> RedirectResponse:
|
|
state_payload = decode_state(state)
|
|
state_nonce_hash = state_payload.get("nonce_hash")
|
|
cookie_nonce = request.cookies.get(STATE_COOKIE_NAME)
|
|
if (
|
|
not isinstance(state_nonce_hash, str)
|
|
or not cookie_nonce
|
|
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), state_nonce_hash)
|
|
):
|
|
# Either the cookie went missing (different browser, expired,
|
|
# cookies blocked) or the state was issued for a different session.
|
|
raise HTTPException(400, "oauth state mismatch — please retry login")
|
|
|
|
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
|
|
|
|
token_data = await exchange_code(code)
|
|
access_token = token_data.get("access_token")
|
|
if not isinstance(access_token, str):
|
|
raise HTTPException(400, "oauth exchange missing access_token")
|
|
user, email = await fetch_github_user(access_token)
|
|
login = user.get("login")
|
|
if not login:
|
|
raise HTTPException(400, "could not resolve GitHub login")
|
|
|
|
await enforce_org_login_gate(login)
|
|
|
|
await upsert_access_token_from_github_response(login, email or "", token_data)
|
|
await _complete_account_mapping(login, email, state_payload.get("link"))
|
|
|
|
session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
|
|
response = RedirectResponse(redirect_to, status_code=302)
|
|
_set_session_cookie(response, session_jwt)
|
|
_clear_state_cookie(response)
|
|
return response
|
|
|
|
|
|
async def _complete_account_mapping(login: str, github_email: str | None, link_token: Any) -> None:
|
|
"""Create/refresh the user mapping after a successful org-gated login.
|
|
|
|
Self-service signup: the user is already org-gated (only members reach
|
|
here), so we record a ``source="self"`` mapping. The Slack identity (user
|
|
id + work email) is carried in the signed account-link token when the flow
|
|
started from an unmapped Slack mention; otherwise we fall back to the
|
|
user's verified GitHub email.
|
|
"""
|
|
link = decode_account_link(link_token) if isinstance(link_token, str) else None
|
|
slack_user_id = link.get("slack_user_id") if link else None
|
|
work_email = (link.get("work_email") if link else None) or github_email
|
|
if not work_email:
|
|
logger.warning("No work email available to map GitHub login %r", login)
|
|
return
|
|
try:
|
|
await upsert_mapping(
|
|
github_login=login,
|
|
work_email=work_email,
|
|
slack_user_id=slack_user_id if isinstance(slack_user_id, str) else None,
|
|
source="self",
|
|
status="active",
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("Failed to persist self-service mapping for %r", login, exc_info=True)
|
|
|
|
|
|
@router.post("/auth/logout")
|
|
async def auth_logout() -> Response:
|
|
response = Response(status_code=204)
|
|
secure, samesite = _cookie_security()
|
|
response.delete_cookie(COOKIE_NAME, path="/", samesite=samesite, secure=secure)
|
|
return response
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return {
|
|
"login": session["sub"],
|
|
"email": session.get("email"),
|
|
"avatar_url": session.get("avatar_url"),
|
|
"is_admin": is_admin(session.get("email")),
|
|
"slack_oauth_enabled": slack_oauth_configured(),
|
|
}
|
|
|
|
|
|
@router.get("/options")
|
|
async def options() -> dict[str, Any]:
|
|
return {"models": SUPPORTED_MODELS}
|
|
|
|
|
|
@router.get("/profile")
|
|
async def get_my_profile(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
profile = await get_profile(session["sub"])
|
|
return profile or {}
|
|
|
|
|
|
@router.put("/profile")
|
|
async def put_my_profile(
|
|
update: ProfileUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
update.validate_pairing()
|
|
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
|
|
|
|
|
@router.get("/my-mapping")
|
|
async def get_my_mapping(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
"""Return the logged-in user's own GitHub↔Slack mapping (or empty)."""
|
|
mapping = await get_mapping(session["sub"])
|
|
return mapping or {}
|
|
|
|
|
|
@router.get("/slack/login")
|
|
async def slack_login(
|
|
_session: dict[str, Any] = _SESSION_DEP,
|
|
) -> RedirectResponse:
|
|
"""Start the Sign in with Slack flow to link the current GitHub account."""
|
|
if not slack_oauth_configured():
|
|
raise HTTPException(500, "Slack OAuth is not configured")
|
|
redirect_uri = f"{_api_base_url()}/dashboard/api/slack/callback"
|
|
nonce = new_state_nonce()
|
|
state = issue_state(
|
|
redirect_to=f"{_frontend_base_url()}/my-settings",
|
|
nonce_hash=hash_state_nonce(nonce),
|
|
)
|
|
response = RedirectResponse(
|
|
build_authorize_url(redirect_uri=redirect_uri, state=state), status_code=302
|
|
)
|
|
_set_slack_state_cookie(response, nonce)
|
|
return response
|
|
|
|
|
|
@router.get("/slack/callback")
|
|
async def slack_callback(
|
|
request: Request,
|
|
code: str,
|
|
state: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> RedirectResponse:
|
|
"""Link the verified Slack identity to the logged-in GitHub user.
|
|
|
|
The Slack member id and email come from Slack's verified OIDC claims, so a
|
|
user can only ever link their own Slack account — no self-asserted values.
|
|
"""
|
|
state_payload = decode_state(state)
|
|
nonce_hash = state_payload.get("nonce_hash")
|
|
cookie_nonce = request.cookies.get(SLACK_STATE_COOKIE_NAME)
|
|
if (
|
|
not isinstance(nonce_hash, str)
|
|
or not cookie_nonce
|
|
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), nonce_hash)
|
|
):
|
|
raise HTTPException(400, "oauth state mismatch — please retry")
|
|
|
|
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
|
|
redirect_uri = f"{_api_base_url()}/dashboard/api/slack/callback"
|
|
|
|
access_token = await exchange_slack_code(code, redirect_uri)
|
|
identity = await fetch_slack_identity(access_token)
|
|
verify_team(identity)
|
|
if not identity.email or not identity.email_verified:
|
|
raise HTTPException(400, "your Slack account has no verified email to link")
|
|
|
|
await upsert_mapping(
|
|
github_login=session["sub"],
|
|
work_email=identity.email,
|
|
slack_user_id=identity.user_id,
|
|
source="slack_oauth",
|
|
status="active",
|
|
)
|
|
|
|
response = RedirectResponse(redirect_to, status_code=302)
|
|
_clear_slack_state_cookie(response)
|
|
return response
|
|
|
|
|
|
@router.get("/team-settings")
|
|
async def api_get_team_settings(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await get_team_settings()
|
|
|
|
|
|
@router.put("/team-settings")
|
|
async def api_put_team_settings(
|
|
update: TeamSettingsUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
return await upsert_team_settings(update)
|
|
|
|
|
|
class EnabledReviewRepoUpdate(BaseModel):
|
|
full_name: str
|
|
enabled: bool
|
|
|
|
|
|
@router.get("/enabled-review-repos")
|
|
async def api_list_enabled_review_repos(
|
|
_session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, list[str]]:
|
|
return {"repos": await list_enabled_review_repos()}
|
|
|
|
|
|
@router.put("/enabled-review-repos")
|
|
async def api_set_enabled_review_repo(
|
|
update: EnabledReviewRepoUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, list[str]]:
|
|
repos = await set_review_repo_enabled(update.full_name, update.enabled)
|
|
return {"repos": repos}
|
|
|
|
|
|
class UserMappingUpsert(BaseModel):
|
|
github_login: str
|
|
work_email: str
|
|
slack_user_id: str | None = None
|
|
|
|
|
|
@router.get("/admin/user-mappings")
|
|
async def admin_list_user_mappings(
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
page = max(page, 1)
|
|
page_size = max(1, min(page_size, 100))
|
|
records = await list_mappings()
|
|
total = len(records)
|
|
start = (page - 1) * page_size
|
|
items = records[start : start + page_size]
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
|
|
|
|
@router.put("/admin/user-mappings")
|
|
async def admin_upsert_user_mapping(
|
|
body: UserMappingUpsert,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
return await upsert_mapping(
|
|
github_login=body.github_login,
|
|
work_email=body.work_email,
|
|
slack_user_id=body.slack_user_id,
|
|
source="admin",
|
|
status="active",
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
|
|
|
|
@router.delete("/admin/user-mappings/{github_login}")
|
|
async def admin_delete_user_mapping(
|
|
github_login: str,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, bool]:
|
|
deleted = await delete_mapping(github_login)
|
|
return {"deleted": deleted}
|
|
|
|
|
|
def _next_link_url(link_header: str | None) -> str | None:
|
|
if not link_header:
|
|
return None
|
|
# GitHub Link header is comma-separated: '<url>; rel="next", <url>; rel="last"'
|
|
for part in link_header.split(","):
|
|
segments = [s.strip() for s in part.split(";")]
|
|
if len(segments) >= 2 and 'rel="next"' in segments[1] and segments[0].startswith("<"):
|
|
return segments[0][1:-1]
|
|
return None
|
|
|
|
|
|
async def _paginate(
|
|
client: httpx.AsyncClient,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
items_key: str | None,
|
|
cap: int = 1000,
|
|
) -> list[dict[str, Any]]:
|
|
"""Follow ``Link: rel="next"`` until exhausted (or cap reached).
|
|
|
|
``items_key`` is the JSON key holding the list when the endpoint returns
|
|
a wrapper object (e.g. ``/user/installations`` returns
|
|
``{"total_count": N, "installations": [...]}``). When ``None`` the
|
|
response body itself is treated as the list.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
next_url: str | None = url
|
|
first = True
|
|
while next_url and len(out) < cap:
|
|
params = {"per_page": "100"} if first else None
|
|
r = await client.get(next_url, headers=headers, params=params)
|
|
if r.status_code == 401:
|
|
raise HTTPException(401, "github token expired, re-login required")
|
|
r.raise_for_status()
|
|
body = r.json()
|
|
page = body.get(items_key, []) if items_key else body
|
|
if isinstance(page, list):
|
|
out.extend(page)
|
|
next_url = _next_link_url(r.headers.get("Link"))
|
|
first = False
|
|
return out
|
|
|
|
|
|
@router.get("/repos")
|
|
async def list_repos(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
"""List repos where open-swe is installed and the user has access.
|
|
|
|
Paginates both ``/user/installations`` and per-installation
|
|
``/user/installations/{id}/repositories`` so users with multiple
|
|
installations or >30 accessible repos get the complete set.
|
|
"""
|
|
login = session["sub"]
|
|
token = await get_valid_access_token(login)
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
installations = await _paginate(
|
|
client,
|
|
"https://api.github.com/user/installations",
|
|
headers=headers,
|
|
items_key="installations",
|
|
)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 401:
|
|
raise
|
|
token = await get_valid_access_token(login, force_refresh=True)
|
|
if not token:
|
|
raise HTTPException(401, "github token expired, re-login required") from exc
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
installations = await _paginate(
|
|
client,
|
|
"https://api.github.com/user/installations",
|
|
headers=headers,
|
|
items_key="installations",
|
|
)
|
|
repositories: list[dict[str, Any]] = []
|
|
for inst in installations:
|
|
inst_id = inst.get("id")
|
|
if inst_id is None:
|
|
continue
|
|
try:
|
|
repos = await _paginate(
|
|
client,
|
|
f"https://api.github.com/user/installations/{inst_id}/repositories",
|
|
headers=headers,
|
|
items_key="repositories",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except httpx.HTTPStatusError:
|
|
continue
|
|
repositories.extend(repos)
|
|
return {
|
|
"installations": [
|
|
{
|
|
"id": i.get("id"),
|
|
"account": (i.get("account") or {}).get("login"),
|
|
"account_type": (i.get("account") or {}).get("type"),
|
|
}
|
|
for i in installations
|
|
],
|
|
"repositories": [
|
|
{"full_name": r.get("full_name"), "private": r.get("private", False)}
|
|
for r in repositories
|
|
if r.get("full_name")
|
|
],
|
|
}
|
|
|
|
|
|
def _raise_for_github_repo_status(status_code: int) -> None:
|
|
if status_code == 401:
|
|
raise HTTPException(401, "github token expired, re-login required")
|
|
if status_code == 404:
|
|
raise HTTPException(404, "repository not found")
|
|
if status_code == 403:
|
|
raise HTTPException(403, "no access to this private repository")
|
|
if status_code != 200:
|
|
raise HTTPException(502, f"github API error ({status_code})")
|
|
|
|
|
|
async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None:
|
|
"""Ensure the repo exists and is readable for style learning.
|
|
|
|
Public repositories are allowed without the GitHub App installed on them.
|
|
Private repositories require the authenticated user to have read access.
|
|
"""
|
|
full_name = normalize_repo_full_name(full_name)
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
owner, name = full_name.split("/", 1)
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.get(
|
|
f"https://api.github.com/repos/{owner}/{name}",
|
|
headers=headers,
|
|
)
|
|
_raise_for_github_repo_status(r.status_code)
|
|
body = r.json()
|
|
if body.get("private") is not True:
|
|
return
|
|
# Private repo: 200 from GitHub implies the user's token can read it.
|
|
|
|
|
|
async def _require_repo_access_for_user(login: str, full_name: str) -> str:
|
|
"""Verify the user can read ``full_name`` on GitHub; return a valid access token."""
|
|
token = await get_valid_access_token(login)
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
try:
|
|
await _assert_repo_available_for_style_analysis(full_name, token)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 401:
|
|
raise
|
|
token = await get_valid_access_token(login, force_refresh=True)
|
|
if not token:
|
|
raise HTTPException(401, "github token expired, re-login required") from exc
|
|
await _assert_repo_available_for_style_analysis(full_name, token)
|
|
return token
|
|
|
|
|
|
@router.get("/review-styles")
|
|
async def api_list_review_styles(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
records = await list_review_styles()
|
|
out: list[dict[str, Any]] = []
|
|
for record in records:
|
|
if record.get("status") == "running":
|
|
synced = await sync_review_style_run_status(record["full_name"])
|
|
out.append(synced)
|
|
else:
|
|
out.append(record)
|
|
return out
|
|
|
|
|
|
@router.post("/review-styles")
|
|
async def api_create_review_style(
|
|
body: ReviewStyleCreate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
await _require_repo_access_for_user(session["sub"], body.full_name)
|
|
return await create_review_style(body.full_name, session["sub"])
|
|
|
|
|
|
@router.get("/review-styles/{full_name:path}")
|
|
async def api_get_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
if record.get("status") == "running":
|
|
record = await sync_review_style_run_status(full_name)
|
|
return record
|
|
|
|
|
|
@router.put("/review-styles/{full_name:path}")
|
|
async def api_update_review_style_prompt(
|
|
full_name: str,
|
|
body: ReviewStylePromptUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
await _require_repo_access_for_user(session["sub"], full_name)
|
|
return await set_custom_prompt(full_name, body.custom_prompt)
|
|
|
|
|
|
@router.post("/review-styles/{full_name:path}/analyze")
|
|
async def api_analyze_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
token = await _require_repo_access_for_user(session["sub"], full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
record = await create_review_style(full_name, session["sub"])
|
|
if record.get("status") == "running":
|
|
record = await sync_review_style_run_status(full_name)
|
|
if record.get("status") == "running":
|
|
raise HTTPException(409, "analysis already running")
|
|
return await start_bootstrap_analysis(
|
|
full_name,
|
|
github_token=token,
|
|
created_by=session["sub"],
|
|
)
|
|
|
|
|
|
@router.post("/review-styles/{full_name:path}/cancel")
|
|
async def api_cancel_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
del session
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
return await cancel_review_style_analysis(full_name)
|
|
|
|
|
|
@router.delete("/review-styles/{full_name:path}")
|
|
async def api_delete_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> Response:
|
|
del session
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
if record.get("status") == "running":
|
|
await cancel_review_style_analysis(full_name)
|
|
await remove_continual_cron(full_name)
|
|
await delete_review_style(full_name)
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/threads")
|
|
async def api_list_threads(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
return await list_dashboard_threads(session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads")
|
|
async def api_create_thread(
|
|
body: ThreadCreateBody,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await create_dashboard_thread(session["sub"], body)
|
|
|
|
|
|
@router.get("/threads/{thread_id}")
|
|
async def api_get_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await get_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads/{thread_id}/messages")
|
|
async def api_send_thread_message(
|
|
thread_id: str,
|
|
body: ThreadMessageBody,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await send_dashboard_message(thread_id, session["sub"], body, email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads/{thread_id}/cancel")
|
|
async def api_cancel_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await cancel_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.delete("/threads/{thread_id}")
|
|
async def api_delete_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> Response:
|
|
await delete_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/threads/{thread_id}/stream")
|
|
async def api_stream_thread(
|
|
thread_id: str,
|
|
request: Request,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> StreamingResponse:
|
|
last_event_id = request.headers.get("last-event-id")
|
|
|
|
async def event_generator():
|
|
async for chunk in stream_dashboard_thread(
|
|
thread_id, session["sub"], email=session.get("email"), last_event_id=last_event_id
|
|
):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
|
)
|