open-swe/agent/dashboard/review_styles.py
Johannes du Plessis 4a55145bb1
feat: outcomes dataset + bootstrap/continual split via skills (#1365)
* fix: reset stale sandbox creation sentinel

Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>

* fix: treat SANDBOX_CREATING as a timestamped cross-process lock

Only reset the sentinel when proven stale (older than the creation
timeout); otherwise wait for the worker that holds the lock so a
concurrent run does not create a duplicate sandbox.

* feat(analyzer): outcomes dataset + bootstrap/continual split via skills

Rename the review_style_analyzer graph to `analyzer` and split it into two
modes, plus capture reviewer finding outcomes for continual learning.

- Outcomes dataset: upsert resolved-by-commit (positive), dismissed (false
  positive), and GitHub/Slack thumbs findings into a single LangSmith dataset
  (openswe-reviewer-outcomes), keyed deterministically per finding+source.
  Emit points wired into update_finding, resolve_finding_thread, and the
  GitHub/Slack reaction handlers.
- Two playbooks delivered as deepagents skills (bootstrap-repo-analysis,
  continual-learning), served as virtual files via a CompositeBackend /skills/
  route + StateBackend (seeded into the run files channel at invoke time, never
  written to the sandbox). Mode is set by the launcher; continual runs fall
  back to the GitHub App installation token.
- Split launcher into start_bootstrap_analysis + start_continual_run; register
  a per-repo nightly continual-learning cron when bootstrap completes.
- New read_finding_outcomes tool feeds confirmed/dismissed findings back to the
  continual playbook.

Tests for outcome label mapping, skills helper, and cron idempotency.

* fix(analyzer): anchor continual cron runs to a real thread_id

The nightly continual-learning cron is threadless, and get_analyzer
early-returns an empty agent when configurable.thread_id is missing — so
every cron-launched run no-op'd before reading outcomes or saving a refined
prompt. Include the repo's deterministic analyzer thread_id in the continual
run configurable so the run executes; the threadless run carries no message
history, so nightly runs don't accumulate context.

* refactor(analyzer): move cron lifecycle calls out of the review-styles store

Drop the inline `analyzer_cron` imports from review_styles.py (added only to
dodge a circular import) by relocating the cron-trigger calls to the layer
above the store: registration to the save_review_style tool (after a prompt is
saved) and removal to the dashboard delete route. review_styles.py is now a
pure store again with top-level imports only.

* refactor: hoist reviewer_outcomes imports to module level

Move the two inline emit_finding_status_outcome imports introduced in this PR
(update_finding, resolve_finding_thread) to top-level imports. reviewer_outcomes
only depends on langsmith, so there is no circular import to avoid.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-01 13:25:12 -07:00

249 lines
7.7 KiB
Python

"""Per-repository review style profiles in LangGraph Store.
Each record holds a synthesized custom prompt (editable in the dashboard),
analysis metadata, and the status of the background style-analysis run.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any, Literal
from langgraph_sdk import get_client
from pydantic import BaseModel, Field, field_validator
logger = logging.getLogger(__name__)
REVIEW_STYLES_NAMESPACE: list[str] = ["review_styles"]
AnalysisStatus = Literal["idle", "running", "completed", "failed"]
def normalize_repo_full_name(raw: str) -> str:
"""Normalize user input to ``owner/repo``."""
v = raw.strip()
for prefix in ("https://github.com/", "http://github.com/", "github.com/"):
if v.lower().startswith(prefix):
v = v[len(prefix) :]
v = v.strip("/")
if v.endswith(".git"):
v = v[:-4]
parts = [p for p in v.split("/") if p]
if len(parts) != 2:
raise ValueError("full_name must be owner/repo")
return f"{parts[0]}/{parts[1]}"
class ReviewStyleCreate(BaseModel):
full_name: str = Field(..., description="GitHub repo in owner/name form")
@field_validator("full_name", mode="before")
@classmethod
def _valid_full_name(cls, v: str) -> str:
return normalize_repo_full_name(v)
class ReviewStylePromptUpdate(BaseModel):
custom_prompt: str
@field_validator("custom_prompt")
@classmethod
def _non_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("custom_prompt cannot be empty")
return v
def _client():
return get_client()
async def _get_value(key: str) -> dict[str, Any] | None:
try:
item = await _client().store.get_item(REVIEW_STYLES_NAMESPACE, key)
except Exception as e:
logger.debug("store get_item failed for %s: %s", key, e)
return None
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _default_record(full_name: str, created_by: str) -> dict[str, Any]:
owner, name = full_name.split("/", 1)
return {
"full_name": full_name,
"owner": owner,
"name": name,
"status": "idle",
"custom_prompt": None,
"analysis_summary": None,
"top_reviewers": [],
"prs_sampled": 0,
"reviews_sampled": 0,
"analysis_thread_id": None,
"analysis_run_id": None,
"continual_cron_id": None,
"error": None,
"created_by": created_by,
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
async def get_review_style(full_name: str) -> dict[str, Any] | None:
return await _get_value(full_name)
async def list_review_styles() -> list[dict[str, Any]]:
result = await _client().store.search_items(REVIEW_STYLES_NAMESPACE, limit=1000)
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
out: list[dict[str, Any]] = []
for item in items or []:
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if isinstance(value, dict):
out.append(value)
out.sort(key=lambda r: r.get("full_name", ""))
return out
async def create_review_style(full_name: str, created_by: str) -> dict[str, Any]:
existing = await get_review_style(full_name)
if existing:
return existing
value = _default_record(full_name, created_by)
await _client().store.put_item(REVIEW_STYLES_NAMESPACE, full_name, value)
return value
async def update_review_style(full_name: str, patch: dict[str, Any]) -> dict[str, Any]:
existing = await get_review_style(full_name) or _default_record(
full_name, patch.get("created_by", "")
)
value = {**existing, **patch, "updated_at": _now_iso()}
await _client().store.put_item(REVIEW_STYLES_NAMESPACE, full_name, value)
return value
def has_saved_prompt(record: dict[str, Any]) -> bool:
prompt = record.get("custom_prompt")
return isinstance(prompt, str) and bool(prompt.strip())
async def set_custom_prompt(full_name: str, custom_prompt: str) -> dict[str, Any]:
existing = await get_review_style(full_name)
patch: dict[str, Any] = {"custom_prompt": custom_prompt}
if existing and existing.get("status") == "running":
patch["status"] = "completed"
patch["error"] = None
return await update_review_style(full_name, patch)
async def reconcile_running_status(
full_name: str,
record: dict[str, Any],
*,
run_status: str | None,
run_missing: bool = False,
) -> dict[str, Any]:
"""Clear stale ``running`` when the analyzer run is done or unreachable."""
if record.get("status") != "running":
return record
terminal_success = frozenset({"success", "completed"})
terminal_failure = frozenset({"error", "failed", "timeout", "interrupted", "cancelled"})
if run_status in terminal_success:
if has_saved_prompt(record):
return await update_review_style(full_name, {"status": "completed", "error": None})
return await mark_analysis_failed(
full_name,
"Analysis finished without saving a prompt. Please retry.",
)
if run_status in terminal_failure:
if has_saved_prompt(record):
return await update_review_style(full_name, {"status": "completed", "error": None})
return await mark_analysis_failed(full_name, "Analysis run ended. Please retry.")
if run_missing:
if has_saved_prompt(record):
return await update_review_style(full_name, {"status": "completed", "error": None})
return await mark_analysis_failed(
full_name,
"Analysis was interrupted or the run is no longer available. Please retry.",
)
return record
async def delete_review_style(full_name: str) -> None:
await _client().store.delete_item(REVIEW_STYLES_NAMESPACE, full_name)
async def mark_analysis_running(
full_name: str,
*,
thread_id: str,
run_id: str | None,
top_reviewers: list[str],
prs_sampled: int,
reviews_sampled: int,
) -> dict[str, Any]:
return await update_review_style(
full_name,
{
"status": "running",
"analysis_thread_id": thread_id,
"analysis_run_id": run_id,
"top_reviewers": top_reviewers,
"prs_sampled": prs_sampled,
"reviews_sampled": reviews_sampled,
"error": None,
},
)
async def mark_analysis_completed(
full_name: str,
*,
custom_prompt: str,
analysis_summary: str,
top_reviewers: list[str],
prs_sampled: int,
reviews_sampled: int,
) -> dict[str, Any]:
return await update_review_style(
full_name,
{
"status": "completed",
"custom_prompt": custom_prompt,
"analysis_summary": analysis_summary,
"top_reviewers": top_reviewers,
"prs_sampled": prs_sampled,
"reviews_sampled": reviews_sampled,
"error": None,
},
)
async def mark_analysis_failed(full_name: str, error: str) -> dict[str, Any]:
return await update_review_style(full_name, {"status": "failed", "error": error})
async def get_repo_custom_prompt(owner: str, repo: str) -> str | None:
"""Return the custom prompt supplement for a repo, if configured."""
full_name = f"{owner}/{repo}"
record = await get_review_style(full_name)
if not record:
return None
prompt = record.get("custom_prompt")
if isinstance(prompt, str) and prompt.strip():
return prompt.strip()
return None