mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 22:03:14 +00:00
* fix(dashboard): managed-cloud OAuth hardening + admin user-mapping endpoint Prepare the dashboard backend for the managed LangGraph Cloud + Vercel runtime, where the API is HTTPS and cross-site from the UI. - OAuth redirect_uri (#2): coerce a schemeless DASHBOARD_API_BASE_URL to https:// in _api_base_url() so GitHub stops rejecting login with "redirect_uri not associated with this application". _cookie_security() now treats a schemeless (managed) value as Secure; SameSite=None too, consistent with the coerced scheme. - OAuth state cookie (#3): document that osw_oauth_state is host-only by design (a Domain cookie is unsafe across *.vercel.app, a public suffix), so login must always start on the stable alias to avoid "oauth state mismatch". Operational contract; no behavioral change. - Admin user mappings (#4): add POST /admin/user-mappings so an admin can set the github_login -> work_email link from the dashboard instead of a raw Store write. New "admin" MappingSource provenance value. * fix(webapp): refresh user-mapping cache on GitHub webhook paths On managed LangGraph Cloud the backend runs multiple replicas, so the per-process GitHub<->work-email mapping cache can be stale on the replica handling a webhook (a mapping created on another replica is invisible until refresh). process_github_pr_comment and process_github_issue now refresh the cache from the durable Store before resolving the author's email, matching the existing Slack mention path (process_slack_mention). * perf(webapp): defer deepagents import to speed custom-app cold start The custom FastAPI app (agent.webapp:app, the langgraph.json http.app) pulled deepagents -> langchain_anthropic -> anthropic into its import graph via dashboard.routes, only to build skill/chat seed files. Defer those create_file_data imports into the functions that use them. Removes deepagents/langchain_anthropic/anthropic from app import entirely and roughly halves module-import wall time (~0.6-0.8s -> ~0.35s warm; larger cold-start saving since native anthropic init is skipped). Behavior identical. (reviewer_diff already imports deepagents under TYPE_CHECKING.) * feat(ui): set work_email user mappings from the admin dashboard Add an "Add / update" form to the admin User mappings section and the adminUpsertUserMapping API client method, wiring the new POST /admin/user-mappings endpoint. Admins can now create or update a github_login -> work_email mapping directly instead of waiting for the user to self-connect Slack. * docs: document managed LangGraph Cloud + Vercel deployment - INSTALLATION §10: add the managed production env triad (LANGGRAPH_URL, DASHBOARD_BASE_URL + DASHBOARD_API_BASE_URL with https://, empty VITE_DASHBOARD_API_BASE_URL for same-origin), the stable-alias login and vercel.json stable-deployment-URL requirements, multi-replica cache note, plus redirect_uri-scheme and oauth-state-mismatch troubleshooting. Refresh the langgraph.json snippet to all six graphs. - README: reframe deployment around the managed migration; link the plan. - deploy/MIGRATION.md: import the self-hosted -> managed migration plan.
539 lines
20 KiB
Python
539 lines
20 KiB
Python
"""Backend for the review page's "chat with this PR" feature.
|
|
|
|
A dedicated, sandbox-less ``chat`` graph (``agent/chat.py``) answers questions
|
|
about one PR. This module mints a per-user chat thread, seeds the PR diff,
|
|
review findings, and an overview as virtual files on the first run, and proxies
|
|
the LangGraph stream/commands/state/history protocol the frontend SDK speaks —
|
|
the chat counterpart of ``thread_api``'s agent proxy, pinned to assistant
|
|
``chat`` and scoped to the review's PR.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
from ..reviewer_diff import fetch_pr_diff
|
|
from ..reviewer_findings import REVIEWER_THREAD_KIND
|
|
from ..utils.github_app import get_github_app_installation_token
|
|
from ..utils.thread_ops import langgraph_client, langgraph_url
|
|
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
|
|
from .review_api import classify_finding, get_pr_head_sha, get_review, reviewer_thread_id
|
|
from .thread_api import (
|
|
_DASHBOARD_STREAM_MODES,
|
|
_langgraph_proxy_headers,
|
|
_require_json_content_type,
|
|
_stream_thread_events,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CHAT_ASSISTANT_ID = "chat"
|
|
_CHAT_SOURCE = "review_chat"
|
|
# Sentinel: caller did not pre-fetch the thread metadata, so fetch it here.
|
|
_UNFETCHED = object()
|
|
_PROXY_REQUEST_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
|
|
_MAX_DIFF_CHARS = 400_000
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(datetime.now(UTC).timestamp() * 1000)
|
|
|
|
|
|
_TITLE_MAX_CHARS = 60
|
|
|
|
|
|
async def _reviewer_thread_exists(owner: str, repo: str, pr_number: int) -> bool:
|
|
try:
|
|
thread = await langgraph_client().threads.get(reviewer_thread_id(owner, repo, pr_number))
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
|
return isinstance(metadata, dict) and metadata.get("kind") == REVIEWER_THREAD_KIND
|
|
|
|
|
|
async def get_review_chat(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
|
|
"""Chat availability for this PR. Threads are minted client-side per chat."""
|
|
return {
|
|
"available": await _reviewer_thread_exists(owner, repo, pr_number),
|
|
"assistant_id": _CHAT_ASSISTANT_ID,
|
|
}
|
|
|
|
|
|
def _chat_thread_search_metadata(
|
|
owner: str, repo: str, pr_number: int, login: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"kind": _CHAT_SOURCE,
|
|
"github_login": login,
|
|
"repo_owner": owner,
|
|
"repo_name": repo,
|
|
"pr_number": pr_number,
|
|
}
|
|
|
|
|
|
async def list_review_chat_threads(
|
|
owner: str, repo: str, pr_number: int, login: str, *, limit: int = 50
|
|
) -> list[dict[str, Any]]:
|
|
"""This user's chat conversations for the PR, newest first."""
|
|
client = langgraph_client()
|
|
try:
|
|
threads = await client.threads.search(
|
|
metadata=_chat_thread_search_metadata(owner, repo, pr_number, login),
|
|
limit=limit,
|
|
sort_by="updated_at",
|
|
sort_order="desc",
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug(
|
|
"chat thread search failed for %s/%s#%s", owner, repo, pr_number, exc_info=True
|
|
)
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
for thread in threads or []:
|
|
if not isinstance(thread, dict):
|
|
continue
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
thread_id = thread.get("thread_id") or thread.get("id")
|
|
if not isinstance(thread_id, str):
|
|
continue
|
|
out.append(
|
|
{
|
|
"thread_id": thread_id,
|
|
"title": metadata.get("title") or "New chat",
|
|
"updated_at": thread.get("updated_at")
|
|
if isinstance(thread.get("updated_at"), str)
|
|
else None,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
async def delete_review_chat_thread(
|
|
owner: str, repo: str, pr_number: int, login: str, thread_id: str
|
|
) -> None:
|
|
"""Delete one of the user's chat threads (scoped + ownership-checked)."""
|
|
metadata = await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
|
|
if metadata is None:
|
|
return # already gone; treat as success
|
|
await langgraph_client().threads.delete(thread_id)
|
|
|
|
|
|
def _first_user_text(params: dict[str, Any]) -> str:
|
|
run_input = params.get("input")
|
|
messages = run_input.get("messages") if isinstance(run_input, dict) else None
|
|
if not isinstance(messages, list):
|
|
return ""
|
|
for message in messages:
|
|
if not isinstance(message, dict) or message.get("type") != "human":
|
|
continue
|
|
content = message.get("content")
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
if isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
text = block.get("text")
|
|
if isinstance(text, str) and text.strip():
|
|
return text.strip()
|
|
return ""
|
|
|
|
|
|
def _derive_title(params: dict[str, Any]) -> str:
|
|
text = _first_user_text(params)
|
|
if not text:
|
|
return "New chat"
|
|
flattened = " ".join(text.split())
|
|
return flattened[:_TITLE_MAX_CHARS] if flattened else "New chat"
|
|
|
|
|
|
def _render_overview(review: dict[str, Any]) -> str:
|
|
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
|
|
lines = [
|
|
f"# {review.get('title') or 'Pull request'} (#{review.get('number')})",
|
|
"",
|
|
f"- Repository: {review.get('full_name', '')}",
|
|
f"- Author: {review.get('author', '')}",
|
|
f"- Head: {review.get('head_ref', '')} @ {review.get('head_sha', '')[:12]}",
|
|
f"- Base: {review.get('base_ref', '')}",
|
|
f"- State: {pr.get('state', '')}",
|
|
f"- Changes: +{pr.get('additions', 0)} -{pr.get('deletions', 0)} "
|
|
f"across {pr.get('changed_files', 0)} file(s), {pr.get('commits', 0)} commit(s)",
|
|
"",
|
|
"## Description",
|
|
"",
|
|
str(pr.get("body") or "_No description provided._"),
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _render_findings(findings: list[dict[str, Any]]) -> str:
|
|
if not findings:
|
|
return "# Review findings\n\n_No findings were published for this PR._"
|
|
out = ["# Review findings", ""]
|
|
for finding in findings:
|
|
group = classify_finding(finding)
|
|
location = finding.get("file") or ""
|
|
start = finding.get("start_line")
|
|
if location and start:
|
|
location = f"{location}:{start}"
|
|
out.append(
|
|
f"## [{group}] {finding.get('title') or 'Untitled'} "
|
|
f"({finding.get('severity', 'low')}/{finding.get('confidence', 'medium')}, "
|
|
f"{finding.get('status', 'open')})"
|
|
)
|
|
if location:
|
|
out.append(f"`{location}`")
|
|
out.append("")
|
|
out.append(str(finding.get("description") or ""))
|
|
suggestion = finding.get("suggestion")
|
|
if suggestion:
|
|
out.append("")
|
|
out.append(f"Suggested change:\n```\n{suggestion}\n```")
|
|
note = finding.get("resolution_note")
|
|
if note:
|
|
out.append("")
|
|
out.append(f"Resolution: {note}")
|
|
out.append("")
|
|
return "\n".join(out)
|
|
|
|
|
|
def _review_head_sha(review: dict[str, Any]) -> str:
|
|
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
|
|
return str(pr.get("head_sha") or review.get("head_sha") or "")
|
|
|
|
|
|
async def _build_pr_context(
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
token: str,
|
|
*,
|
|
review: dict[str, Any] | None = None,
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""Fetch diff + findings + overview as seedable files; return ``(files, head_sha)``.
|
|
|
|
Accepts an already-fetched ``review`` to avoid re-fetching it when the caller
|
|
has just read it to decide whether a reseed is needed.
|
|
"""
|
|
# Deferred import: deepagents pulls langchain_anthropic / anthropic (~0.7s)
|
|
# into the import graph. It is otherwise dragged into the custom FastAPI
|
|
# app's cold start via dashboard.routes; only needed when seeding chat
|
|
# files, so import it lazily here.
|
|
from deepagents.backends.utils import create_file_data
|
|
|
|
if review is None:
|
|
review = await get_review(owner, repo, pr_number)
|
|
findings = review.get("findings") if isinstance(review.get("findings"), list) else []
|
|
head_sha = _review_head_sha(review)
|
|
diff = await fetch_pr_diff(owner=owner, repo=repo, pr_number=pr_number, token=token) or ""
|
|
if len(diff) > _MAX_DIFF_CHARS:
|
|
diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated]\n"
|
|
files = {
|
|
"/pr/overview.md": create_file_data(_render_overview(review)),
|
|
"/pr/diff.patch": create_file_data(diff or "[diff unavailable]"),
|
|
"/pr/findings.md": create_file_data(_render_findings(findings)),
|
|
}
|
|
return files, head_sha
|
|
|
|
|
|
async def _get_chat_thread_metadata(thread_id: str) -> dict[str, Any] | None:
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
return thread.get("metadata") if isinstance(thread, dict) else None
|
|
|
|
|
|
async def assert_chat_thread_access(
|
|
thread_id: str, owner: str, repo: str, pr_number: int, login: str
|
|
) -> dict[str, Any] | None:
|
|
"""Authorize a client-supplied chat thread id before proxying to LangGraph.
|
|
|
|
Chat threads are private per viewer and their ids come from the client, so
|
|
every proxy route must confirm the caller owns the thread it names. Returns
|
|
the thread metadata when it exists and belongs to ``login`` for this PR, or
|
|
``None`` when the thread doesn't exist yet (it's created lazily on the first
|
|
run, so there is nothing to leak). Raises 404 when a thread exists but is
|
|
owned by someone else or scoped to a different repo/PR — this also rejects
|
|
reviewer (or any non-chat) threads, whose ``kind`` is not ``_CHAT_SOURCE``.
|
|
"""
|
|
metadata = await _get_chat_thread_metadata(thread_id)
|
|
if metadata is None:
|
|
return None
|
|
owns = (
|
|
metadata.get("kind") == _CHAT_SOURCE
|
|
and metadata.get("github_login") == login
|
|
and metadata.get("repo_owner") == owner
|
|
and metadata.get("repo_name") == repo
|
|
and metadata.get("pr_number") == pr_number
|
|
)
|
|
if not owns:
|
|
raise HTTPException(404, "chat not found")
|
|
return metadata
|
|
|
|
|
|
async def _create_chat_thread(
|
|
thread_id: str, owner: str, repo: str, pr_number: int, login: str, *, title: str
|
|
) -> None:
|
|
now_ms = _now_ms()
|
|
metadata = {
|
|
"kind": _CHAT_SOURCE,
|
|
"source": _CHAT_SOURCE,
|
|
"github_login": login,
|
|
"repo_owner": owner,
|
|
"repo_name": repo,
|
|
"pr_number": pr_number,
|
|
"title": title,
|
|
"created_at_ms": now_ms,
|
|
"updated_at_ms": now_ms,
|
|
}
|
|
await langgraph_client().threads.create(
|
|
thread_id=thread_id, metadata=metadata, if_exists="do_nothing"
|
|
)
|
|
|
|
|
|
def _normalize_chat_model(configurable: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
model_id = configurable.get("chat_model_id")
|
|
effort = configurable.get("chat_effort")
|
|
if (
|
|
isinstance(model_id, str)
|
|
and model_id in SUPPORTED_MODEL_IDS
|
|
and isinstance(effort, str)
|
|
and model_supports_effort(model_id, effort)
|
|
):
|
|
return model_id, effort
|
|
return None, None
|
|
|
|
|
|
async def _enrich_chat_command(
|
|
command: dict[str, Any],
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
login: str,
|
|
thread_id: str,
|
|
thread_metadata: Any = _UNFETCHED,
|
|
) -> dict[str, Any]:
|
|
if command.get("method") != "run.start":
|
|
return command
|
|
|
|
params = command.get("params")
|
|
if not isinstance(params, dict):
|
|
params = {}
|
|
command["params"] = params
|
|
|
|
# Reuse the metadata the access check already fetched to avoid a duplicate
|
|
# thread read on the hot path; fall back to fetching it when not supplied.
|
|
if thread_metadata is _UNFETCHED:
|
|
thread_metadata = await _get_chat_thread_metadata(thread_id)
|
|
metadata = thread_metadata
|
|
created = metadata is None
|
|
if created:
|
|
await _create_chat_thread(
|
|
thread_id, owner, repo, pr_number, login, title=_derive_title(params)
|
|
)
|
|
metadata = {}
|
|
|
|
client_config = params.get("config")
|
|
if not isinstance(client_config, dict):
|
|
client_config = {}
|
|
client_configurable = client_config.get("configurable")
|
|
if not isinstance(client_configurable, dict):
|
|
client_configurable = {}
|
|
|
|
configurable: dict[str, Any] = {
|
|
"thread_id": thread_id,
|
|
"source": _CHAT_SOURCE,
|
|
"github_login": login,
|
|
"chat_repo_owner": owner,
|
|
"chat_repo_name": repo,
|
|
"chat_pr_number": pr_number,
|
|
"reviewer_thread_id": reviewer_thread_id(owner, repo, pr_number),
|
|
}
|
|
model_id, effort = _normalize_chat_model(client_configurable)
|
|
if model_id and effort:
|
|
configurable["chat_model_id"] = model_id
|
|
configurable["chat_effort"] = effort
|
|
|
|
# Seed PR context on the thread's first run, and reseed whenever the PR head
|
|
# has moved since the last seed — otherwise the chat keeps answering from a
|
|
# stale diff/findings while the review page already shows the current head.
|
|
stored_head = metadata.get("chat_head_sha") if isinstance(metadata, dict) else None
|
|
stored_head = stored_head if isinstance(stored_head, str) else ""
|
|
|
|
# Detect head movement with a single lightweight PR lookup instead of a full
|
|
# ``get_review`` (which also fetches check runs + the reviewer thread). The
|
|
# full review is only fetched below, by ``_build_pr_context``, when we
|
|
# actually need to reseed. On lookup failure, keep the existing context.
|
|
review: dict[str, Any] | None = None
|
|
needs_seed = created
|
|
if not created:
|
|
current_head = await get_pr_head_sha(owner, repo, pr_number)
|
|
needs_seed = bool(current_head) and current_head != stored_head
|
|
|
|
if needs_seed:
|
|
try:
|
|
token = await get_github_app_installation_token(repositories=[repo])
|
|
if not token:
|
|
raise HTTPException(503, "GitHub App token unavailable")
|
|
pr_files, head_sha = await _build_pr_context(
|
|
owner, repo, pr_number, token, review=review
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
# An existing chat can keep answering from its last seeded context, so
|
|
# a transient reseed failure shouldn't break the conversation. A fresh
|
|
# chat (or one never seeded) has nothing to fall back to, so surface it.
|
|
if created or not stored_head:
|
|
if isinstance(exc, HTTPException):
|
|
raise
|
|
logger.warning(
|
|
"Failed to seed PR chat context for %s/%s#%s", owner, repo, pr_number
|
|
)
|
|
raise HTTPException(502, "could not load PR context") from exc
|
|
logger.warning(
|
|
"Failed to reseed PR chat context for %s/%s#%s; keeping last seeded context",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
configurable["chat_head_sha"] = stored_head
|
|
else:
|
|
if head_sha:
|
|
configurable["chat_head_sha"] = head_sha
|
|
await langgraph_client().threads.update(
|
|
thread_id=thread_id, metadata={"chat_head_sha": head_sha}
|
|
)
|
|
elif stored_head:
|
|
configurable["chat_head_sha"] = stored_head
|
|
run_input = params.get("input")
|
|
if not isinstance(run_input, dict):
|
|
run_input = {}
|
|
existing_files = run_input.get("files")
|
|
run_input["files"] = {
|
|
**(existing_files if isinstance(existing_files, dict) else {}),
|
|
**pr_files,
|
|
}
|
|
params["input"] = run_input
|
|
elif stored_head:
|
|
configurable["chat_head_sha"] = stored_head
|
|
|
|
params["assistant_id"] = _CHAT_ASSISTANT_ID
|
|
params.setdefault("stream_mode", list(_DASHBOARD_STREAM_MODES))
|
|
params.setdefault("stream_resumable", True)
|
|
params["config"] = {**client_config, "configurable": configurable}
|
|
command["params"] = params
|
|
return command
|
|
|
|
|
|
async def proxy_review_chat_commands(
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
login: str,
|
|
thread_id: str,
|
|
body: bytes,
|
|
*,
|
|
content_type: str = "application/json",
|
|
) -> tuple[int, bytes, str | None]:
|
|
# Reject threads the caller doesn't own; a missing thread is created lazily
|
|
# below on the first `run.start` (with the caller as owner). Reuse the
|
|
# fetched metadata to avoid a second thread read during enrichment.
|
|
metadata = await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
|
|
_require_json_content_type(content_type)
|
|
try:
|
|
parsed = json.loads(body)
|
|
except json.JSONDecodeError as exc:
|
|
raise HTTPException(400, "command body must be a JSON object") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise HTTPException(400, "command body must be a JSON object")
|
|
|
|
enriched = await _enrich_chat_command(
|
|
parsed,
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
login=login,
|
|
thread_id=thread_id,
|
|
thread_metadata=metadata,
|
|
)
|
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/commands"
|
|
headers = _langgraph_proxy_headers(content_type=content_type)
|
|
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
|
response = await client.post(url, content=json.dumps(enriched).encode(), headers=headers)
|
|
return response.status_code, response.content, response.headers.get("content-type")
|
|
|
|
|
|
async def proxy_review_chat_stream_events(
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
login: str,
|
|
thread_id: str,
|
|
body: bytes,
|
|
*,
|
|
content_type: str = "application/json",
|
|
) -> AsyncIterator[bytes]:
|
|
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
|
|
_require_json_content_type(content_type)
|
|
return _stream_thread_events(thread_id, body, content_type)
|
|
|
|
|
|
async def _proxy_passthrough(
|
|
method: str, thread_id: str, suffix: str, body: bytes | None, content_type: str
|
|
) -> tuple[int, bytes, str | None]:
|
|
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/{suffix}"
|
|
headers = _langgraph_proxy_headers(content_type=content_type)
|
|
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
|
if method == "GET":
|
|
response = await client.get(url, headers=headers)
|
|
else:
|
|
response = await client.post(url, content=body or b"{}", headers=headers)
|
|
return response.status_code, response.content, response.headers.get("content-type")
|
|
|
|
|
|
async def proxy_review_chat_state(
|
|
owner: str, repo: str, pr_number: int, login: str, thread_id: str
|
|
) -> tuple[int, bytes, str | None]:
|
|
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
|
|
status_code, content, media_type = await _proxy_passthrough(
|
|
"GET", thread_id, "state", None, "application/json"
|
|
)
|
|
# The chat thread is created lazily on the first run, so an initial getState
|
|
# hits a missing thread. Return an empty idle state so the SDK hydrates a
|
|
# fresh thread instead of surfacing the 404 as a hard error.
|
|
if status_code == 404:
|
|
empty = json.dumps({"values": {}, "next": []}).encode()
|
|
return 200, empty, "application/json"
|
|
return status_code, content, media_type
|
|
|
|
|
|
async def proxy_review_chat_history(
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
login: str,
|
|
thread_id: str,
|
|
body: bytes,
|
|
*,
|
|
content_type: str = "application/json",
|
|
) -> tuple[int, bytes, str | None]:
|
|
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
|
|
_require_json_content_type(content_type)
|
|
status_code, content, media_type = await _proxy_passthrough(
|
|
"POST", thread_id, "history", body, content_type
|
|
)
|
|
# The thread is created lazily on the first run; before then, hydration
|
|
# history reads hit a missing thread. Return an empty list so the SDK
|
|
# treats it as a fresh thread instead of erroring.
|
|
if status_code == 404:
|
|
return 200, b"[]", "application/json"
|
|
return status_code, content, media_type
|