mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +00:00
* feat(dashboard): restructure Open SWE Review tab + wire create_prs Restructures the dashboard around two related changes the reviewer settings have been asking for: - Wire profile.create_prs. Defaults to true (opt-out); when off the system prompt gets a `Pull Request Policy Override` section telling the agent to push the branch and notify with the branch URL instead of opening a PR. Removes the noop Slack Notifications / Allow Artifacts / First Name / Last Name controls and their schema fields. - Repositories opt-in for Open SWE Review. New per-team enabled list stored in the LangGraph Store (`["enabled_review_repos"]`). Every reviewer webhook chokepoint now goes through `_is_repo_enabled_for_review` which AND-combines the existing env allowlist with the dashboard list. Default is empty (opt-in) — admins enable repos per-installation from the new Repositories page nested under Open SWE Review. - Open SWE Review tab now mirrors the Cursor "rules" pattern: main page shows installation rows + a Rules entry; both drill into nested pages (/review/repositories/$owner and /review/styles) with a back link. - Adds the new logo/favicon assets shipped from sidebar + html head. Tests pass with a new autouse fixture (`tests/conftest.py`) that defaults `is_review_repo_enabled` to True for existing allowlist tests. * fix(dashboard): make main content scroll independently of the sidebar Outer flex container was min-h-svh, so it grew with main's content and the whole page scrolled — sidebar moved with it. Pin to h-svh + overflow-hidden so the sidebar stays put and only <main> scrolls. * fix(dashboard): make disabled repo toggles obviously disabled Switch's disabled state used opacity-50 against a muted background, so the not-admin state looked nearly identical to the off state. Bump to opacity-40 + grayscale, and wrap each repo toggle in a span carrying a native hover tooltip explaining why it's disabled. * fix(switch): handle base-ui's data-disabled state base-ui's Switch.Root sets data-disabled (not the HTML disabled attribute) when disabled, so Tailwind's disabled: variant never matches and the button keeps its cursor-pointer + clickable look. Mirror the styling under the data-[disabled] variant and add pointer-events-none so the disabled state is both visible and actually unclickable. * feat(dashboard): paginate per-installation repository list 20 repos per page with Prev / page X of Y / Next controls at the bottom. Pager only renders when there are more than 20 repos. Page resets to 0 when navigating between installations. * feat(dashboard): global default model selectors for Agent + Reviewer Adds team-wide default model + reasoning effort for both agents in the Admin tab so operators can switch models without redeploying. Resolution chain: Agent: hardcoded -> LLM_MODEL_ID env -> team default -> user profile Reviewer: hardcoded -> LLM_MODEL_ID env -> team default -> per-call configurable Team defaults live in team_settings and are validated against the SUPPORTED_MODELS allowlist + the model's supported reasoning efforts. 'Inherit from env' clears the override and falls back to LLM_MODEL_ID. * refactor(models): drop LLM_MODEL_ID env in favour of the team default The team default is now the single source of truth for the runtime model choice; per-user (agent) and per-call configurable (reviewer) selections still win on top. When no admin has touched the team default, it surfaces the hardcoded fallback (DEFAULT_MODEL_ID + its default effort), so the admin UI's dropdown is always pre-populated with a sensible value. The Admin UI loses the 'Inherit from env' option since there is no longer an env layer to inherit from. * chore(models): set hardcoded fallback to gpt-5.5 medium Decouple the team-default boot value (gpt-5.5 / medium) from each model's ProfileForm-suggested default_effort so we can change one without nudging the other. The Opus xhigh default for new user profiles is unchanged. * feat(dashboard): trigger-mode copy, Coming Soon badges, logout in My Settings - Rename trigger mode 'ready_for_review' -> 'once_per_pr' with new description copy that matches the screenshot. Legacy stored values fall back to 'every_push' on read so the UI never shows an unknown selection. - Add a 'Coming soon' badge + greyed-out + disabled state on the controls that don't have runtime consumers yet: Trigger Mode, Autofix Mode, Autofix Severity Threshold, and Automatically fix CI failures. SettingsRow grew a comingSoon prop to keep this consistent. - My Settings drops the noop PR Preferences section and adds a Sign Out button. preferred_pr_destination is removed from the profile schema; old records get the field popped on next write. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
555 lines
24 KiB
Python
555 lines
24 KiB
Python
"""Reviewer graph factory.
|
||
|
||
Mirrors `agent.server.get_agent`'s sandbox lifecycle but configures a deep
|
||
agent for code review only:
|
||
|
||
- Deterministic repo prep (clone-or-fetch + checkout) before the agent's first
|
||
model call so the LLM doesn't burn tokens narrating ``gh repo clone``.
|
||
- A computed unified diff and the set of (file, line) tuples in that diff,
|
||
passed via the runnable config so ``add_finding`` can validate at creation
|
||
time rather than failing at GitHub-publish time.
|
||
- A reviewer-specific tool set: ``add_finding``, ``update_finding``,
|
||
``list_findings``, ``publish_review``. No commit/push/PR-opening tools.
|
||
- A system prompt that pins the single-evolving-findings model, in-diff-only
|
||
discipline, severity ladder, and the watch-mode reconciliation flow.
|
||
"""
|
||
# ruff: noqa: E402
|
||
|
||
import logging
|
||
import warnings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from langgraph.graph.state import RunnableConfig
|
||
from langgraph.pregel import Pregel
|
||
|
||
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
|
||
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
|
||
|
||
from deepagents import create_deep_agent
|
||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||
|
||
from .middleware import (
|
||
SanitizeToolInputsMiddleware,
|
||
SlackAssistantStatusMiddleware,
|
||
ToolErrorMiddleware,
|
||
)
|
||
from .reviewer_findings import (
|
||
list_findings as list_findings_async,
|
||
)
|
||
from .server import (
|
||
DEFAULT_LLM_MAX_TOKENS,
|
||
DEFAULT_RECURSION_LIMIT,
|
||
MODEL_CALL_RECURSION_LIMIT,
|
||
ensure_sandbox_for_thread,
|
||
graph_loaded_for_execution,
|
||
)
|
||
from .tools import (
|
||
add_finding,
|
||
fetch_url,
|
||
http_request,
|
||
list_findings,
|
||
publish_review,
|
||
update_finding,
|
||
web_search,
|
||
)
|
||
from .utils.auth import resolve_github_token
|
||
from .utils.github_token import get_github_token_from_thread
|
||
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
|
||
from .utils.sandbox_paths import aresolve_sandbox_work_dir
|
||
|
||
REVIEWER_PROMPT_TEMPLATE = """You are a specialized code reviewer agent. Your job is to review one GitHub PR and publish a single review.
|
||
|
||
Sandbox: `{working_dir}`. Invoke `gh` as `GH_TOKEN=dummy gh <command>`.
|
||
|
||
Fetch the diff:
|
||
|
||
```
|
||
GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}
|
||
```
|
||
|
||
Re-review (user message says "A new commit has been pushed"):
|
||
|
||
```
|
||
GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/<last_reviewed_sha>...<head_sha> -H "Accept: application/vnd.github.v3.diff"
|
||
```
|
||
|
||
Clone repo first so that you can grep for full file context:
|
||
|
||
```
|
||
GH_TOKEN=dummy gh repo clone {repo_owner}/{repo_name} && cd {repo_name} && git checkout <head_sha>
|
||
```
|
||
|
||
Tools: `add_finding`, `update_finding`, `list_findings`, `publish_review`.
|
||
Call `publish_review` once at the end.
|
||
|
||
Re-review: for each open finding, `update_finding(id, status="resolved")` if
|
||
fixed, `update_finding` with new fields + `note` if changed, otherwise do
|
||
nothing. Add net-new findings with `add_finding`.
|
||
|
||
# The bar: file a finding only if it passes these criteria
|
||
|
||
1. You can anchor it to a specific changed line and quote that line.
|
||
2. You can name the concrete failure mode — what breaks at build time,
|
||
runtime, or for users, given the code as it exists today.
|
||
3. **Diff-anchor:** the finding's file appears in the PR diff hunk, OR you
|
||
proved a regression via `git show <base_sha>:path` vs
|
||
`git show <head_sha>:path` on a callsite of a symbol whose signature
|
||
changed in the diff. Do not file bugs in unrelated files or subsystems
|
||
based on inference alone.
|
||
|
||
# Do NOT file
|
||
|
||
- **Style / naming / convention nits.** No "rename this", "extract a
|
||
constant", "use a different helper", "remove redundant ?.", "metric label
|
||
is ambiguous", "this could be cleaner". The one exception: typos that break
|
||
behavior (template binding, undefined CSS prefix, exported name a template
|
||
references by string).
|
||
- **Speculation.** No "if X is ever null", "if a future caller passes Y",
|
||
"if admin changes default at runtime", "could potentially race". You need
|
||
a concrete trigger reachable from the current code.
|
||
- **Scope-policing / architectural critique.** No "this PR doesn't achieve
|
||
its stated goal", "this is unrelated to the PR's purpose", "the design
|
||
should be different".
|
||
- **Pre-existing issues** not introduced by this diff.
|
||
- **Out-of-diff / wrong-subsystem speculation.** Do not file findings in
|
||
files absent from the PR diff unless you proved base-vs-head regression on
|
||
a changed symbol's callsite. Do not pivot to unrelated subsystems when
|
||
checklist items in changed files remain unchecked.
|
||
- **Same-bug fan-out.** If the same defect appears in N files (e.g.
|
||
`forEach(async ...)` across three handlers), file ONE finding that lists
|
||
all sites in `description`. Not N findings.
|
||
|
||
# Common defect patterns
|
||
|
||
Walk these every review. Most real defects fall into one of these:
|
||
|
||
- **Refactor regression** — nil-check, logging, async-ness, lock scope, or
|
||
sentinel handling dropped vs. base. Compare each touched function's old
|
||
body to its new body.
|
||
- **Wrong operator / wrong method** — `&&` vs `||`, `===` on objects that
|
||
need value comparison, case-sensitive substring checks in case-insensitive
|
||
paths, wrong metric/helper function for the path, inverted ternary,
|
||
off-by-one substring index.
|
||
- **Copy-paste / wrong-variable** — error message names a different param
|
||
than the check, function returns the original variable after mutating a
|
||
local copy, wrong dict key in updater.
|
||
- **Async footguns** — `forEach(async ...)`, method became `async` but
|
||
callers not awaited, fire-and-forget on cleanup paths.
|
||
- **Read-modify-write that should be atomic** — `counter: row.counter + 1`
|
||
in an ORM (use `increment`), count-then-insert TOCTOU, narrowed mutex.
|
||
- **API / framework contract drift** — signature changed but not all
|
||
implementers / callers updated; new abstract method left `pass` in a
|
||
subclass; framework hook/predicate naming contract broken; Javadoc /
|
||
docstring contract violated.
|
||
- **Lookup-key mismatch** — stored under key A, queried under key B; normalized
|
||
column compared to a non-normalized parameter.
|
||
- **Tautology / stub / unreachable branch** — both branches return the
|
||
same value, "not implemented" stubs committed, dead branch behind an
|
||
always-true/false guard.
|
||
- **Falsy-edge / truthy-zero** — `if x:` when x can be 0.0; `if result:`
|
||
when result is a valid empty collection; `if not None` traps.
|
||
- **Nil/None deref** — optional access without guard, `Optional.get()`
|
||
without `isPresent`, accessing `metadata["key"]` that may not exist.
|
||
- **Security / trust boundaries** — SSRF via `open(url)` or fetch of
|
||
user-controlled URLs without allowlist; OAuth state or nonce reused across
|
||
requests; cache that trusts hits for grants but re-checks denials (or
|
||
vice versa); missing origin/referer validation; X-Frame-Options or CSP
|
||
weakened to ALLOWALL.
|
||
- **Test quality** — docstring describes different behavior than assertions;
|
||
fixed `sleep` instead of condition-based wait; test HTTP method doesn't
|
||
match the route; monkeypatched `time.sleep` that makes the test not wait.
|
||
- **Migration / raw SQL** — inserts/updates bypass model normalization
|
||
(host stripping, case folding) that ORM-created rows get.
|
||
- **UI/template contract** — missing stable React keys on changed list
|
||
rendering; template syntax errors; changed theme/contrast expressions that
|
||
invert or materially alter the base behavior.
|
||
- **Shell/build portability** — changed scripts rely on platform-specific
|
||
command flags in paths that run in Linux CI or shared developer tooling.
|
||
|
||
# When to read beyond the diff
|
||
|
||
The diff is the starting point, not the whole job. Spend the grep budget
|
||
when:
|
||
|
||
- **Title says rename / refactor / move / extract / split** → mandatory
|
||
base-vs-head pass on every touched function. Was the nil-check preserved?
|
||
The logging? traceID or log fields? The async-ness? The lock scope?
|
||
Removed logging/tracing/nil-guard without an equivalent replacement path
|
||
is a finding.
|
||
- **A function signature or interface changes** → grep implementers and
|
||
callers. Are they all updated?
|
||
- **A new lookup helper appears** → find where the data was stored. Do the
|
||
keys match?
|
||
- **A stdlib / library call you're not 100% sure of** → verify the API
|
||
exists with the expected signature (Python version, ORM decorator
|
||
semantics, web-API contracts).
|
||
- **Auth / permissions / caching code** → trace the resolution path. What
|
||
does this actually return when the cache hits? When it misses? Don't just
|
||
suggest tidying.
|
||
- **Consumer / multiprocessing code** → verify Process API semantics
|
||
(`is_alive`, spawn vs fork context), shared pool lifecycle vs per-partition
|
||
strategy lifecycle, and metric tag key consistency.
|
||
|
||
# Review workflow — complete passes in order
|
||
|
||
Do not skip to deep flow analysis until Passes 1–4 are done. File findings
|
||
from earlier passes first; they have priority at publish time.
|
||
|
||
## Pass 1: Mechanical grep (every changed file)
|
||
|
||
Grep the diff for these patterns on changed lines. Each hit is a candidate
|
||
finding unless already covered:
|
||
|
||
- `Optional.get()` / `.get()` without `isPresent()` / nil deref without guard
|
||
- `forEach(async` / fire-and-forget async callbacks
|
||
- CLI/process exit calls that bypass the intended framework exit-code path
|
||
- `hash(` used for cache keys; `if sample_rate:` / falsy-zero on numeric 0
|
||
- `not implemented` stubs; tautological branches (both paths same value)
|
||
- Inverted or mismatched old/new feature flags
|
||
- Removed imports, log fields, traceID, nil-guards, or lock scope vs base
|
||
- `open(`, `fetch(` on user-controlled URLs; weak origin/referer checks
|
||
- Empty ORM updates that skip timestamp hooks or no-op unexpectedly
|
||
- `retryCount + 1` / read-modify-write counters (prefer `{{ increment: 1 }}`)
|
||
- Mutable or import-time defaults (`now()` at import, shared list/dict)
|
||
- Wrong operator: `&&` vs `||`, `===` on objects needing `.isSame()`
|
||
- Changed React list rendering without a stable `key` prop
|
||
- Framework hook or predicate methods whose changed name no longer matches
|
||
the framework contract
|
||
|
||
## Pass 2: Diff-line audit (every changed hunk)
|
||
|
||
For each changed hunk, ask: **what did this exact line change?** Read the
|
||
old line with `git show <base_sha>:path` when the hunk is a refactor,
|
||
rename, or logic change. Prioritize literal changes (wrong variable,
|
||
wrong return, wrong substring index, wrong dict key) over inferred control-
|
||
flow bugs in nearby unchanged code.
|
||
|
||
## Pass 3: Security / auth / cache (when diff touches these)
|
||
|
||
Mandatory when the diff includes auth, OAuth, permissions, caching, embed
|
||
URLs, headers (X-Frame-Options, CSP), or `postMessage`:
|
||
|
||
- Trace cache hit vs miss: are grants and denials trusted symmetrically?
|
||
- OAuth: per-request state/nonce vs static signature; redirect_uri parity
|
||
- Every `metadata[...]`, pipeline state, and optional association access guarded
|
||
- SSRF, origin validation completeness, raw HTML bypass paths
|
||
- ERB/template syntax errors on changed templates
|
||
|
||
## Pass 4: Pipeline sweep (each touched handler/function)
|
||
|
||
After the first finding in a handler, model method, or consumer, continue
|
||
the same function — do not stop:
|
||
|
||
validate → filter/dedupe → DB write → external API → email/calendar/task
|
||
enqueue → error path (never assign error/nil to cache). Independent failure
|
||
modes in different subsystems are separate findings.
|
||
|
||
On signature/interface changes: grep all implementers and callers.
|
||
|
||
On rename/refactor PRs: base-vs-head every touched function for dropped
|
||
nil-checks, logging, traceID, async-ness, lock scope, metric helper args.
|
||
|
||
On paginator/consumer/multiprocessing changes: negative slice/offset branches,
|
||
sort-key type assumptions, spawned-process type checks, shared pool lifecycle
|
||
vs per-partition strategy, shutdown terminate loops.
|
||
|
||
## Pass 5: Deep flow (only if cap slots remain)
|
||
|
||
Only after Passes 1–4. File additional findings here if critical/high and
|
||
introduced by this diff. Do **not** file adjacent high-severity bugs in
|
||
unrelated subsystems when Pass 1–3 checklist items in changed files are
|
||
still unchecked. Do not file perf/cadence opinions unless they cause
|
||
correctness failure.
|
||
|
||
# Before publish_review
|
||
|
||
1. Call `list_findings`. You must have walked Passes 1–4; if the diff
|
||
touches production code and you have zero findings, you stopped too early.
|
||
2. **Dedup:** reject duplicate `(file, line, failure_mode)` entries.
|
||
3. **Rank** open findings: (a) checklist/archetype hits from Passes 1–3,
|
||
(b) severity, (c) category diversity across files. Prefer one finding
|
||
listing N identical sites over N separate findings for the same defect.
|
||
4. Keep only the strongest small set of findings. No two findings in the
|
||
same file unless completely independent failure modes (different
|
||
user-visible symptom).
|
||
5. Verify accidental-commit findings (submodules, debug files) appear in
|
||
the PR diff before filing.
|
||
6. Cross-check PR title and top changed directories: if a major changed
|
||
prefix has zero findings, re-read that prefix before publishing.
|
||
|
||
# Severity rubric (tied to runtime consequence)
|
||
|
||
- `critical` — panic, crash, data loss, auth bypass, security regression.
|
||
- `high` — wrong result for users; clear correctness bug.
|
||
- `medium` — correctness in an edge case; concurrency hazard with a
|
||
reachable trigger.
|
||
- `low` — a real defect with limited blast radius (typo that breaks a
|
||
binding, log level wrong in a hot path, UX bug with concrete impact).
|
||
|
||
Architectural opinions, naming preferences, and micro-perf are not
|
||
severities — they're not findings.
|
||
|
||
# Other rules
|
||
|
||
- Read-only. Do not commit, push, or use `gh pr review` / `gh api .../reviews`.
|
||
- One finding per defect (with the fan-out rule above for cross-file bugs).
|
||
- Include `suggestion` only when the fix is ≤4 lines and obvious.
|
||
- Publish a concise review: prefer the highest-confidence findings that pass
|
||
the bar. Use fewer when fewer issues are defensible; publish zero only
|
||
after the ordered passes found no concrete regression.
|
||
"""
|
||
|
||
|
||
REVIEWER_EVAL_PROMPT_SUFFIX = """
|
||
# Eval mode — calibration
|
||
|
||
This run is scored against a closed set of golden review comments per PR.
|
||
The dataset expects 1-5 comments per PR (mean ~2).
|
||
|
||
- **Hard minimum: at least 1 finding per review.** Publishing zero is only
|
||
acceptable after you have explicitly walked Passes 1-4 and have nothing
|
||
that meets the bar. If you reach `publish_review` empty, return to the
|
||
checklist — silence costs more than a defensible medium-severity finding.
|
||
- **Hard cap: at most 3 findings per review.**
|
||
- Findings that match a golden comment are rewarded; findings that don't
|
||
are penalized. Missing a golden comment is also penalized. Optimize for
|
||
*defects a careful maintainer would also flag* — not coverage of every
|
||
observation you make.
|
||
"""
|
||
|
||
|
||
def _reviewer_system_prompt(
|
||
working_dir: str,
|
||
*,
|
||
repo_owner: str,
|
||
repo_name: str,
|
||
pr_number: int | str,
|
||
reviewer_eval: bool = False,
|
||
repo_style_prompt: str | None = None,
|
||
) -> str:
|
||
prompt = REVIEWER_PROMPT_TEMPLATE.format(
|
||
working_dir=working_dir,
|
||
repo_owner=repo_owner or "<owner>",
|
||
repo_name=repo_name or "<repo>",
|
||
pr_number=pr_number if pr_number != "" else "<pr_number>",
|
||
)
|
||
if reviewer_eval:
|
||
prompt = f"{prompt}\n{REVIEWER_EVAL_PROMPT_SUFFIX}"
|
||
if repo_style_prompt:
|
||
prompt = (
|
||
f"{prompt}\n\n"
|
||
"# Repository-specific review style\n\n"
|
||
"The following rules were learned from this repository's historical "
|
||
"PR reviews. Apply them when they agree with the global bar above; "
|
||
"they refine tone, severity, and what this team typically flags.\n\n"
|
||
f"{repo_style_prompt}"
|
||
)
|
||
return prompt
|
||
|
||
|
||
def _build_first_review_context(
|
||
*,
|
||
pr_url: str,
|
||
repo_owner: str,
|
||
repo_name: str,
|
||
pr_number: int,
|
||
base_sha: str,
|
||
head_sha: str,
|
||
) -> str:
|
||
return (
|
||
f"## Pull request to review\n\n"
|
||
f"- repo: {repo_owner}/{repo_name}\n"
|
||
f"- pr_number: {pr_number}\n"
|
||
f"- url: {pr_url}\n"
|
||
f"- base_sha: {base_sha}\n"
|
||
f"- head_sha: {head_sha}\n\n"
|
||
f"Fetch the diff yourself with "
|
||
f"`GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}`, "
|
||
f"then review using the ordered passes (mechanical grep → diff-line audit "
|
||
f"→ security/auth if applicable → pipeline sweep → deep flow).\n\n"
|
||
f"This is a first review — there are no existing findings. Record issues "
|
||
f"with `add_finding`, call `list_findings` to rank and dedup, then "
|
||
f"`publish_review` once at the end (cap 3)."
|
||
)
|
||
|
||
|
||
def _build_re_review_context(
|
||
*,
|
||
pr_url: str,
|
||
repo_owner: str,
|
||
repo_name: str,
|
||
pr_number: int,
|
||
last_reviewed_sha: str,
|
||
head_sha: str,
|
||
existing_findings_block: str,
|
||
) -> str:
|
||
return (
|
||
f"## A new commit has been pushed\n\n"
|
||
f"- repo: {repo_owner}/{repo_name}\n"
|
||
f"- pr_number: {pr_number}\n"
|
||
f"- url: {pr_url}\n"
|
||
f"- previous reviewed SHA: {last_reviewed_sha}\n"
|
||
f"- new HEAD SHA: {head_sha}\n\n"
|
||
f"## Existing findings\n\n{existing_findings_block}\n\n"
|
||
f"Fetch the diff since the previous reviewed SHA yourself with "
|
||
f"`GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/"
|
||
f'{last_reviewed_sha}...{head_sha} -H "Accept: application/vnd.github.v3.diff"`, '
|
||
f"then review only what's in that diff.\n\n"
|
||
f"For each open finding above, decide whether the new commits resolved "
|
||
f'it (`update_finding(id, status="resolved")`), left it unchanged '
|
||
f"(no action), or changed it materially (`update_finding` with new "
|
||
f"fields + a `note`). Then add any net-new findings introduced by the "
|
||
f"new diff, and call `publish_review` once at the end."
|
||
)
|
||
|
||
|
||
def _format_existing_findings(findings: list[dict]) -> str:
|
||
if not findings:
|
||
return "_(none)_"
|
||
lines: list[str] = []
|
||
for f in findings:
|
||
if f.get("status") != "open":
|
||
continue
|
||
location = f.get("file", "<unknown>")
|
||
start = f.get("start_line")
|
||
end = f.get("end_line")
|
||
if start is not None and end is not None:
|
||
location += f":{start}" if start == end else f":{start}-{end}"
|
||
lines.append(
|
||
f"- [{f.get('id')}] ({f.get('severity')}, {f.get('category')}) "
|
||
f"{location} — {f.get('description', '').strip()}"
|
||
)
|
||
return "\n".join(lines) if lines else "_(no open findings)_"
|
||
|
||
|
||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
||
thread_id = config["configurable"].get("thread_id", None)
|
||
|
||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||
|
||
if thread_id is None or not graph_loaded_for_execution(config):
|
||
logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
|
||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||
|
||
if config["configurable"].get("source"):
|
||
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
|
||
thread_id
|
||
)
|
||
if cached_token and cached_encrypted:
|
||
config["metadata"]["github_token_encrypted"] = cached_encrypted
|
||
config["metadata"]["github_token_expires_at"] = cached_expires_at
|
||
del cached_token
|
||
else:
|
||
_token, new_encrypted, new_expires_at = await resolve_github_token(config, thread_id)
|
||
config["metadata"]["github_token_encrypted"] = new_encrypted
|
||
config["metadata"]["github_token_expires_at"] = new_expires_at
|
||
del _token
|
||
|
||
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
|
||
|
||
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
|
||
|
||
repo_config = config["configurable"].get("repo") or {}
|
||
repo_owner = str(repo_config.get("owner", ""))
|
||
repo_name = str(repo_config.get("name", ""))
|
||
base_sha = str(config["configurable"].get("base_sha", "") or "")
|
||
head_sha = str(config["configurable"].get("head_sha", "") or "")
|
||
pr_number = config["configurable"].get("pr_number")
|
||
pr_url = str(config["configurable"].get("pr_url", "") or "")
|
||
last_reviewed_sha = str(config["configurable"].get("last_reviewed_sha", "") or "")
|
||
is_re_review = bool(config["configurable"].get("re_review"))
|
||
|
||
# Hotfix: prep was producing empty diffs for some PRs and the agent
|
||
# silently published "no issues found". The agent now fetches the diff
|
||
# itself via `gh pr diff` (or `gh api ...compare...` on re-review).
|
||
# `add_finding`'s in-diff line-range validation is skipped when no
|
||
# diff_line_set is set in config — we trust the agent's anchors.
|
||
config["configurable"]["diff_text"] = ""
|
||
config["configurable"]["diff_line_set"] = None
|
||
|
||
review_context = ""
|
||
if pr_number is not None and isinstance(pr_number, int):
|
||
if is_re_review and last_reviewed_sha:
|
||
existing_findings = await list_findings_async(thread_id)
|
||
review_context = _build_re_review_context(
|
||
pr_url=pr_url,
|
||
repo_owner=repo_owner,
|
||
repo_name=repo_name,
|
||
pr_number=pr_number,
|
||
last_reviewed_sha=last_reviewed_sha,
|
||
head_sha=head_sha,
|
||
existing_findings_block=_format_existing_findings(existing_findings),
|
||
)
|
||
else:
|
||
review_context = _build_first_review_context(
|
||
pr_url=pr_url,
|
||
repo_owner=repo_owner,
|
||
repo_name=repo_name,
|
||
pr_number=pr_number,
|
||
base_sha=base_sha,
|
||
head_sha=head_sha,
|
||
)
|
||
|
||
from .dashboard.team_settings import get_team_default_model
|
||
|
||
configured_model_id = config["configurable"].get("reviewer_model_id")
|
||
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
|
||
if isinstance(configured_model_id, str) and configured_model_id:
|
||
model_id = configured_model_id
|
||
reasoning_effort = configured_effort if isinstance(configured_effort, str) else None
|
||
else:
|
||
model_id, reasoning_effort = await get_team_default_model("reviewer")
|
||
logger.info(
|
||
"Using team default reviewer model: model=%s effort=%s",
|
||
model_id,
|
||
reasoning_effort,
|
||
)
|
||
model_kwargs = provider_model_kwargs(
|
||
model_id,
|
||
reasoning_effort,
|
||
max_tokens=DEFAULT_LLM_MAX_TOKENS,
|
||
openai_reasoning_default=DEFAULT_LLM_REASONING,
|
||
)
|
||
|
||
reviewer_eval = (
|
||
config["configurable"].get("reviewer_eval") is True
|
||
or config["configurable"].get("eval") is True
|
||
)
|
||
repo_style_prompt: str | None = None
|
||
if repo_owner and repo_name:
|
||
from .dashboard.review_styles import get_repo_custom_prompt
|
||
|
||
repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name)
|
||
system_prompt = _reviewer_system_prompt(
|
||
f"{work_dir}/{repo_name}" if repo_name else work_dir,
|
||
repo_owner=repo_owner,
|
||
repo_name=repo_name,
|
||
pr_number=pr_number if isinstance(pr_number, int) else "",
|
||
reviewer_eval=reviewer_eval,
|
||
repo_style_prompt=repo_style_prompt,
|
||
)
|
||
if review_context:
|
||
system_prompt = f"{system_prompt}\n\n{review_context}"
|
||
|
||
return create_deep_agent(
|
||
model=make_model(model_id, **model_kwargs),
|
||
system_prompt=system_prompt,
|
||
tools=[
|
||
add_finding,
|
||
update_finding,
|
||
list_findings,
|
||
publish_review,
|
||
web_search,
|
||
fetch_url,
|
||
http_request,
|
||
],
|
||
backend=sandbox_backend,
|
||
middleware=[
|
||
SanitizeToolInputsMiddleware(),
|
||
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
|
||
ToolErrorMiddleware(),
|
||
SlackAssistantStatusMiddleware(),
|
||
],
|
||
).with_config(config)
|