mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 19:03:18 +00:00
189 lines
6.6 KiB
Python
189 lines
6.6 KiB
Python
|
|
"""REST API for the plan-review page: read the plan, approve, or request changes.
|
||
|
|
|
||
|
|
Comment threads themselves live in the collaborative Yjs document (BlockNote's
|
||
|
|
``YjsThreadStore``) and are harvested client-side; on approve/reject the client
|
||
|
|
sends the harvested feedback here, where it is formatted and handed to the agent
|
||
|
|
as the instruction for the follow-up run. The agent never sees comments during
|
||
|
|
review — only this aggregated feedback at the decision point.
|
||
|
|
|
||
|
|
Permissions: any authenticated org member can read a surfaced thread and request
|
||
|
|
changes (reject); only the thread owner can approve.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException
|
||
|
|
from langgraph_sdk import get_client
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from .oauth import require_same_origin_for_mutations, require_session
|
||
|
|
from .plan_store import (
|
||
|
|
PLAN_STATUS_APPROVED,
|
||
|
|
PLAN_STATUS_REVISING,
|
||
|
|
get_plan_content,
|
||
|
|
set_plan_status,
|
||
|
|
)
|
||
|
|
from .thread_api import (
|
||
|
|
_repo_config_from_metadata,
|
||
|
|
_thread_is_readable,
|
||
|
|
_thread_source,
|
||
|
|
_user_owns_thread,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
plan_router = APIRouter(
|
||
|
|
prefix="/dashboard/api/plan",
|
||
|
|
tags=["plan"],
|
||
|
|
dependencies=[Depends(require_same_origin_for_mutations)],
|
||
|
|
)
|
||
|
|
_SESSION_DEP = Depends(require_session)
|
||
|
|
|
||
|
|
|
||
|
|
class PlanComment(BaseModel):
|
||
|
|
author: str | None = None
|
||
|
|
body: str = ""
|
||
|
|
quote: str | None = None
|
||
|
|
resolved: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
class PlanDecisionBody(BaseModel):
|
||
|
|
comments: list[PlanComment] = Field(default_factory=list)
|
||
|
|
|
||
|
|
|
||
|
|
async def _thread_metadata(thread_id: str) -> dict[str, Any]:
|
||
|
|
client = get_client()
|
||
|
|
try:
|
||
|
|
thread = await client.threads.get(thread_id)
|
||
|
|
except Exception as exc: # noqa: BLE001
|
||
|
|
raise HTTPException(404, "thread not found") from exc
|
||
|
|
metadata = (
|
||
|
|
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
|
||
|
|
)
|
||
|
|
return metadata if isinstance(metadata, dict) else {}
|
||
|
|
|
||
|
|
|
||
|
|
@plan_router.get("/{thread_id}")
|
||
|
|
async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
||
|
|
metadata = await _thread_metadata(thread_id)
|
||
|
|
if not _thread_is_readable(metadata):
|
||
|
|
raise HTTPException(404, "thread not found")
|
||
|
|
login = session["sub"]
|
||
|
|
email = session.get("email")
|
||
|
|
content = await get_plan_content(thread_id) or {}
|
||
|
|
return {
|
||
|
|
"threadId": thread_id,
|
||
|
|
"status": content.get("status") or metadata.get("plan_status") or "planning",
|
||
|
|
"markdown": content.get("markdown", ""),
|
||
|
|
"isOwner": _user_owns_thread(metadata, login, email),
|
||
|
|
"user": {
|
||
|
|
"id": login,
|
||
|
|
"login": login,
|
||
|
|
"email": email,
|
||
|
|
"name": session.get("name") or login,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@plan_router.post("/{thread_id}/approve")
|
||
|
|
async def approve_plan(
|
||
|
|
thread_id: str,
|
||
|
|
body: PlanDecisionBody,
|
||
|
|
session: dict[str, Any] = _SESSION_DEP,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
metadata = await _thread_metadata(thread_id)
|
||
|
|
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
|
||
|
|
raise HTTPException(403, "only the plan owner can approve")
|
||
|
|
await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False)
|
||
|
|
feedback = _format_comments(body.comments)
|
||
|
|
if feedback:
|
||
|
|
text = (
|
||
|
|
"The plan has been approved. Implement it now, taking this reviewer "
|
||
|
|
f"feedback into account:\n\n{feedback}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
text = "The plan has been approved. Implement it now as described in the plan."
|
||
|
|
await _dispatch_followup(thread_id, metadata, text, plan_mode=False)
|
||
|
|
return {"status": PLAN_STATUS_APPROVED}
|
||
|
|
|
||
|
|
|
||
|
|
@plan_router.post("/{thread_id}/reject")
|
||
|
|
async def reject_plan(
|
||
|
|
thread_id: str,
|
||
|
|
body: PlanDecisionBody,
|
||
|
|
session: dict[str, Any] = _SESSION_DEP,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
metadata = await _thread_metadata(thread_id)
|
||
|
|
if not _thread_is_readable(metadata):
|
||
|
|
raise HTTPException(404, "thread not found")
|
||
|
|
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
|
||
|
|
feedback = _format_comments(body.comments)
|
||
|
|
text = (
|
||
|
|
"The plan needs changes before implementation. Address this reviewer "
|
||
|
|
"feedback and publish an updated plan with the save_plan tool:\n\n"
|
||
|
|
f"{feedback or '(no specific comments were left)'}"
|
||
|
|
)
|
||
|
|
await _dispatch_followup(thread_id, metadata, text, plan_mode=True)
|
||
|
|
return {"status": PLAN_STATUS_REVISING}
|
||
|
|
|
||
|
|
|
||
|
|
def _format_comments(comments: list[PlanComment]) -> str:
|
||
|
|
lines: list[str] = []
|
||
|
|
for index, comment in enumerate(comments, start=1):
|
||
|
|
body = comment.body.strip()
|
||
|
|
if not body:
|
||
|
|
continue
|
||
|
|
author = (comment.author or "reviewer").strip()
|
||
|
|
quote = (comment.quote or "").strip()
|
||
|
|
status = " (resolved)" if comment.resolved else ""
|
||
|
|
if quote:
|
||
|
|
lines.append(f'{index}. On "{quote}"{status}:\n - {author}: {body}')
|
||
|
|
else:
|
||
|
|
lines.append(f"{index}. {author}{status}: {body}")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
async def _dispatch_followup(
|
||
|
|
thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool
|
||
|
|
) -> None:
|
||
|
|
"""Continue the existing thread with a new instruction run.
|
||
|
|
|
||
|
|
Runs on the same LangGraph thread, so the agent resumes from the checkpoint
|
||
|
|
with the full planning history plus this instruction. The configurable is
|
||
|
|
rebuilt from the thread's stored owner/repo/Slack context so the agent can
|
||
|
|
push, open a PR, and reply in the original channel.
|
||
|
|
"""
|
||
|
|
configurable: dict[str, Any] = {
|
||
|
|
"thread_id": thread_id,
|
||
|
|
"source": _thread_source(metadata) or "slack",
|
||
|
|
}
|
||
|
|
email = metadata.get("triggering_user_email")
|
||
|
|
if isinstance(email, str) and email:
|
||
|
|
configurable["user_email"] = email
|
||
|
|
login = metadata.get("github_login")
|
||
|
|
if isinstance(login, str) and login:
|
||
|
|
configurable["github_login"] = login
|
||
|
|
repo = _repo_config_from_metadata(metadata)
|
||
|
|
if repo:
|
||
|
|
configurable["repo"] = repo
|
||
|
|
source_context = metadata.get("source_context")
|
||
|
|
if isinstance(source_context, dict):
|
||
|
|
slack_thread = source_context.get("slack_thread")
|
||
|
|
if isinstance(slack_thread, dict):
|
||
|
|
configurable["slack_thread"] = slack_thread
|
||
|
|
# Carry the decision to the follow-up run: approve continues out of plan
|
||
|
|
# mode (implement), reject stays in plan mode (revise the plan).
|
||
|
|
configurable["plan_mode"] = plan_mode
|
||
|
|
|
||
|
|
client = get_client()
|
||
|
|
await client.runs.create(
|
||
|
|
thread_id,
|
||
|
|
"agent",
|
||
|
|
input={"messages": [{"role": "user", "content": text}]},
|
||
|
|
config={"configurable": configurable},
|
||
|
|
if_not_exists="create",
|
||
|
|
)
|