mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
* feat: add PR trace resolution (#1612) * feat: add PR trace resolution Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: inject reviewer trace context as JSON Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: address review on PR trace resolution Use the documented LangSmith metadata filter syntax (and(eq(metadata_key,...), eq(metadata_value,...))) instead of has(metadata, '{...}'), which does not match runs — _list_thread_runs was silently returning nothing. Bound full-text searches to a 90-day window so they don't hit LangSmith's large-window rate limit. Also folds in the best-effort branch->head-sha resolver (dropping the weighted scoring/threshold + repo/file evidence + GitHub hydration), sandbox JSON injection, and the admin "Resolve trace" dry-run endpoint. The IDOR findings are moot: resolve_pr_to_threads/summarize_agent_session were removed; resolution now runs deterministically from the trusted run config with no model-controlled pr_url or thread_id. * fix: scope branch trace search to the repo Branch names like fix-tests aren't unique across repos (or older PRs) in a shared tracing project, so an unscoped branch hit could resolve to an unrelated thread and write its runs into the reviewer sandbox. Require the repo slug to co-occur with the branch in matched runs; the full head SHA stays unscoped since it is globally unique. Addresses open-swe review on PR #1612. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit69148f54f5) * fix: post reviewer resolution notes verbatim (#1624) * fix: post reviewer resolution notes verbatim Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: stabilize dashboard follow-up e2e Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: preserve dashboard attribution in e2e Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: make e2e attribution marker durable Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: only echo found e2e attribution Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: check live dashboard attribution in e2e Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit5da3d0c657) * chore: opt-in tracemalloc to attribute unclosed aiohttp sessions (#1657) Prod logs show bursts of 'Unclosed client session' (aiohttp), leaking fds + memory, but the warning omits the allocation site. When DEBUG_TRACEMALLOC is set, start tracemalloc at webapp import so aiohttp appends an 'Object allocated at' traceback naming the exact source. Inert when the env var is unset. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 320bb39ab1f5cd7a2acdac52c7b375854334176c) * feat: add PR review link route (#1698) * feat: add PR review link route Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: avoid duplicate review shortcut runs Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 52fe29168814d7936ee6612b9c81f33339bb05b0) * style: clean up leftover blank lines from cherry-pick conflict resolution * fix(e2e): drop duplicate _ATTRIBUTION_RE from cherry-pick The reviewer-misc pick re-added _ATTRIBUTION_RE next to _latest_attribution, but the constant was already defined at module top (line 57, alongside _PLAN_URL_RE) via the earlier #81 sync. Remove the redundant redefinition; _latest_attribution resolves the surviving top-level constant. --------- Co-authored-by: Johannes du Plessis <johannes@langchain.dev> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com> Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
387 lines
16 KiB
Python
387 lines
16 KiB
Python
"""Team-wide Open SWE Review (Bugbot) settings stored in LangGraph Store.
|
|
|
|
A single record keyed ``"default"`` keeps all instance-wide reviewer
|
|
configuration in one place. Per-repo style prompts live in
|
|
:mod:`agent.dashboard.review_styles`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
|
|
from langgraph_sdk import get_client
|
|
from pydantic import BaseModel, field_validator, model_validator
|
|
|
|
from .options import (
|
|
SUPPORTED_MODEL_IDS,
|
|
default_model_pair,
|
|
model_supports_effort,
|
|
provider_fallback_pair,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"]
|
|
TEAM_SETTINGS_KEY = "default"
|
|
|
|
# Cap the org-wide guidelines so a runaway value can't dominate the reviewer
|
|
# prompt. Generous enough for a detailed policy, small enough to stay bounded.
|
|
ORG_GUIDELINES_MAX_CHARS = 10_000
|
|
REVIEW_TRACING_PROJECT_MAX_CHARS = 256
|
|
# Sea Haven review baseline seeded as the org-wide guidelines default. Surfaces
|
|
# in the reviewer prompt for every repo until an admin overrides it with a
|
|
# non-empty value via the dashboard (PUT /team-settings). Keep it stack-agnostic
|
|
# and well under ORG_GUIDELINES_MAX_CHARS.
|
|
DEFAULT_ORG_REVIEW_GUIDELINES = """\
|
|
Sea Haven review baseline (applies to every repo unless a repo-specific guideline overrides it):
|
|
|
|
- Severity: map findings to critical / high / medium / low. Reserve critical and high for correctness bugs, security issues, data loss, or broken contracts — not style.
|
|
- Secrets & config: flag any hardcoded secret, credential, or real `.env`/config value committed to source, and any sensitive value placed outside the platform's secrets manager.
|
|
- Security surface (raise as high): changes to authentication/authorization or access checks; IAM/policy/permission or infrastructure-access changes; changes to the exported signature or contract of a public handler/endpoint; and untrusted-input handling (request parsing, deserialization, file uploads, SSRF-prone fetches, and template/SQL/command construction — flag unescaped interpolation of dynamic or user-controlled data).
|
|
- Tests: flag new behavior that ships without a corresponding test, and "fixes" that only silence a check (added excludes, `noqa` / `# type: ignore`, skipped or `xfail`ed tests).
|
|
- Naming & conventions: flag resources or code that break the repo's established naming and layout conventions.
|
|
- Deferred work: a finding the author chooses to defer must be captured in a tracked issue, not dropped silently.
|
|
|
|
Only file a finding that anchors to a changed line and names a concrete failure mode. Do not police pre-existing issues outside the diff or raise pure style nits."""
|
|
|
|
|
|
class TeamSettingsUpdate(BaseModel):
|
|
review_draft_prs: bool = False
|
|
pr_summaries: bool = True
|
|
review_trace_links: bool = True
|
|
review_tracing_project: str | None = None
|
|
org_guidelines: str | None = None
|
|
default_agent_model: str | None = None
|
|
default_agent_reasoning_effort: str | None = None
|
|
default_agent_subagent_model: str | None = None
|
|
default_agent_subagent_reasoning_effort: str | None = None
|
|
default_repo: str | None = None
|
|
default_reviewer_model: str | None = None
|
|
default_reviewer_reasoning_effort: str | None = None
|
|
default_reviewer_subagent_model: str | None = None
|
|
default_reviewer_subagent_reasoning_effort: str | None = None
|
|
default_grouping_model: str | None = None
|
|
default_grouping_reasoning_effort: str | None = None
|
|
default_chat_model: str | None = None
|
|
default_chat_reasoning_effort: str | None = None
|
|
|
|
@field_validator("org_guidelines", mode="before")
|
|
@classmethod
|
|
def _normalize_org_guidelines(cls, v: object) -> str | None:
|
|
if v is None:
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("org_guidelines must be a string")
|
|
text = v.strip()
|
|
if not text:
|
|
return None
|
|
if len(text) > ORG_GUIDELINES_MAX_CHARS:
|
|
raise ValueError(
|
|
f"org_guidelines must be at most {ORG_GUIDELINES_MAX_CHARS} characters"
|
|
)
|
|
return text
|
|
|
|
@field_validator("review_tracing_project", mode="before")
|
|
@classmethod
|
|
def _normalize_review_tracing_project(cls, v: object) -> str | None:
|
|
if v is None:
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("review_tracing_project must be a string")
|
|
text = v.strip()
|
|
if not text:
|
|
return None
|
|
if len(text) > REVIEW_TRACING_PROJECT_MAX_CHARS:
|
|
raise ValueError(
|
|
"review_tracing_project must be at most "
|
|
f"{REVIEW_TRACING_PROJECT_MAX_CHARS} characters"
|
|
)
|
|
return text
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_model_pairs(self) -> TeamSettingsUpdate:
|
|
_validate_model_effort_pair(
|
|
self.default_agent_model, self.default_agent_reasoning_effort, "agent"
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_agent_subagent_model,
|
|
self.default_agent_subagent_reasoning_effort,
|
|
"agent subagent",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_reviewer_model, self.default_reviewer_reasoning_effort, "reviewer"
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_reviewer_subagent_model,
|
|
self.default_reviewer_subagent_reasoning_effort,
|
|
"reviewer subagent",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_grouping_model,
|
|
self.default_grouping_reasoning_effort,
|
|
"review diff grouping",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
|
)
|
|
return self
|
|
|
|
|
|
def _validate_model_effort_pair(model: str | None, effort: str | None, role: str) -> None:
|
|
if model is None and effort is None:
|
|
return
|
|
if model is None:
|
|
raise ValueError(f"{role} reasoning effort set without a model")
|
|
if model not in SUPPORTED_MODEL_IDS:
|
|
raise ValueError(f"unsupported {role} model: {model}")
|
|
if effort is None or not model_supports_effort(model, effort):
|
|
raise ValueError(f"effort {effort!r} not supported by {role} model {model!r}")
|
|
|
|
|
|
def _client():
|
|
return get_client()
|
|
|
|
|
|
def _env_default_repo() -> str | None:
|
|
owner = os.environ.get("DEFAULT_REPO_OWNER", "").strip()
|
|
name = os.environ.get("DEFAULT_REPO_NAME", "").strip()
|
|
return f"{owner}/{name}" if owner and name else None
|
|
|
|
|
|
def _parse_repo(value: object) -> dict[str, str] | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
owner, sep, name = value.strip().partition("/")
|
|
if not sep or not owner.strip() or not name.strip():
|
|
return None
|
|
return {"owner": owner.strip(), "name": name.strip()}
|
|
|
|
|
|
def _default_settings() -> dict[str, Any]:
|
|
fallback_model, fallback_effort = default_model_pair()
|
|
return {
|
|
"review_draft_prs": False,
|
|
"pr_summaries": True,
|
|
"review_trace_links": True,
|
|
"review_tracing_project": None,
|
|
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
|
"default_agent_model": fallback_model,
|
|
"default_agent_reasoning_effort": fallback_effort,
|
|
"default_agent_subagent_model": fallback_model,
|
|
"default_agent_subagent_reasoning_effort": fallback_effort,
|
|
"default_repo": _env_default_repo(),
|
|
"default_reviewer_model": fallback_model,
|
|
"default_reviewer_reasoning_effort": fallback_effort,
|
|
"default_reviewer_subagent_model": fallback_model,
|
|
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
|
# No hardcoded grouping default: unset means "inherit the Reviewer
|
|
# subagent default".
|
|
"default_grouping_model": None,
|
|
"default_grouping_reasoning_effort": None,
|
|
# No hardcoded chat default: unset means "inherit the Agent default".
|
|
"default_chat_model": None,
|
|
"default_chat_reasoning_effort": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
async def get_team_settings() -> dict[str, Any]:
|
|
defaults = _default_settings()
|
|
try:
|
|
item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY)
|
|
except Exception as e:
|
|
logger.debug("team settings lookup failed: %s", e)
|
|
return defaults
|
|
if item is None:
|
|
return defaults
|
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
|
if not isinstance(value, dict):
|
|
return defaults
|
|
# Skip None-valued model fields so legacy records (or PUTs that cleared the
|
|
# selection) still surface the hardcoded default instead of a null.
|
|
overlay = {k: v for k, v in value.items() if v is not None}
|
|
merged = {**defaults, **overlay}
|
|
for stale_field in (
|
|
"trigger_mode",
|
|
"autofix_mode",
|
|
"autofix_severity_threshold",
|
|
"autofix_enabled",
|
|
"review_author_context_enabled",
|
|
):
|
|
merged.pop(stale_field, None)
|
|
return merged
|
|
|
|
|
|
async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
|
value: dict[str, Any] = {
|
|
"review_draft_prs": update.review_draft_prs,
|
|
"pr_summaries": update.pr_summaries,
|
|
"review_trace_links": update.review_trace_links,
|
|
"review_tracing_project": update.review_tracing_project,
|
|
"org_guidelines": update.org_guidelines,
|
|
"default_agent_model": update.default_agent_model,
|
|
"default_agent_reasoning_effort": update.default_agent_reasoning_effort,
|
|
"default_agent_subagent_model": update.default_agent_subagent_model,
|
|
"default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort,
|
|
"default_repo": update.default_repo,
|
|
"default_reviewer_model": update.default_reviewer_model,
|
|
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
|
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
|
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
|
"default_grouping_model": update.default_grouping_model,
|
|
"default_grouping_reasoning_effort": update.default_grouping_reasoning_effort,
|
|
"default_chat_model": update.default_chat_model,
|
|
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
|
|
return value
|
|
|
|
|
|
async def get_team_default_repo() -> dict[str, str] | None:
|
|
settings = await get_team_settings()
|
|
return _parse_repo(settings.get("default_repo"))
|
|
|
|
|
|
async def get_team_default_model(
|
|
role: Literal["agent", "reviewer", "chat"],
|
|
) -> tuple[str, str]:
|
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``.
|
|
|
|
Always returns a valid pair, resolved in order: the admin-configured pair if
|
|
still supported; otherwise the newest supported model for the same provider
|
|
(so a stale Anthropic/OpenAI selection stays on its provider rather than
|
|
jumping cross-provider); otherwise the hardcoded global default from
|
|
:func:`agent.dashboard.options.default_model_pair`.
|
|
|
|
``"chat"`` (the review-page PR chat) has no hardcoded default: when its
|
|
admin setting is unset/invalid it inherits the team **agent** default.
|
|
"""
|
|
settings = await get_team_settings()
|
|
if role == "chat":
|
|
model = settings.get("default_chat_model")
|
|
effort = settings.get("default_chat_reasoning_effort")
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return _resolve_default_pair(model, effort)
|
|
# Inherit the Agent default when no chat-specific model is configured.
|
|
model = settings.get("default_agent_model")
|
|
effort = settings.get("default_agent_reasoning_effort")
|
|
elif role == "agent":
|
|
model = settings.get("default_agent_model")
|
|
effort = settings.get("default_agent_reasoning_effort")
|
|
else:
|
|
model = settings.get("default_reviewer_model")
|
|
effort = settings.get("default_reviewer_reasoning_effort")
|
|
return _resolve_default_pair(model, effort)
|
|
|
|
|
|
async def get_team_default_model_pair(
|
|
role: Literal["agent", "reviewer"],
|
|
) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
"""Return default ``(main, subagent)`` model pairs for ``role`` from one store read."""
|
|
settings = await get_team_settings()
|
|
if role == "agent":
|
|
main = _resolve_default_pair(
|
|
settings.get("default_agent_model"),
|
|
settings.get("default_agent_reasoning_effort"),
|
|
)
|
|
subagent = _resolve_default_pair(
|
|
settings.get("default_agent_subagent_model"),
|
|
settings.get("default_agent_subagent_reasoning_effort"),
|
|
)
|
|
else:
|
|
main = _resolve_default_pair(
|
|
settings.get("default_reviewer_model"),
|
|
settings.get("default_reviewer_reasoning_effort"),
|
|
)
|
|
subagent = _resolve_default_pair(
|
|
settings.get("default_reviewer_subagent_model"),
|
|
settings.get("default_reviewer_subagent_reasoning_effort"),
|
|
)
|
|
return main, subagent
|
|
|
|
|
|
async def get_team_default_grouping_model() -> tuple[str, str]:
|
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for the
|
|
review diff-grouping pass.
|
|
|
|
When no grouping-specific model is configured (or it's no longer
|
|
supported), inherit the team **reviewer subagent** default — the grouping
|
|
pass is a cheap, fast companion to the reviewer, so it should track that
|
|
cheaper tier rather than the primary reviewer model.
|
|
"""
|
|
settings = await get_team_settings()
|
|
model = settings.get("default_grouping_model")
|
|
effort = settings.get("default_grouping_reasoning_effort")
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return _resolve_default_pair(model, effort)
|
|
return _resolve_default_pair(
|
|
settings.get("default_reviewer_subagent_model"),
|
|
settings.get("default_reviewer_subagent_reasoning_effort"),
|
|
)
|
|
|
|
|
|
async def get_team_review_trace_links_enabled() -> bool:
|
|
"""Return whether GitHub review bodies should include a LangSmith trace link."""
|
|
settings = await get_team_settings()
|
|
return bool(settings.get("review_trace_links", True))
|
|
|
|
|
|
async def get_team_review_tracing_project() -> str | None:
|
|
"""Return the LangSmith tracing project used for PR trace resolution."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("review_tracing_project")
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
async def get_org_review_guidelines() -> str | None:
|
|
"""Return the org-wide reviewer guidelines supplement, if configured."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("org_guidelines")
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
async def get_team_default_subagent_model(
|
|
role: Literal["agent", "reviewer"],
|
|
) -> tuple[str, str]:
|
|
"""Return the team-wide default subagent ``(model_id, reasoning_effort)`` for ``role``."""
|
|
settings = await get_team_settings()
|
|
if role == "agent":
|
|
model = settings.get("default_agent_subagent_model")
|
|
effort = settings.get("default_agent_subagent_reasoning_effort")
|
|
else:
|
|
model = settings.get("default_reviewer_subagent_model")
|
|
effort = settings.get("default_reviewer_subagent_reasoning_effort")
|
|
return _resolve_default_pair(model, effort)
|
|
|
|
|
|
def _resolve_default_pair(model: object, effort: object) -> tuple[str, str]:
|
|
"""Supported pair if valid, else same-provider fallback, else global default."""
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return model, effort
|
|
provider_pair = provider_fallback_pair(model, effort)
|
|
if provider_pair is not None:
|
|
return provider_pair
|
|
return default_model_pair()
|