mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
Part of the domain-reorg adoption (build plan step C2): fork content,
upstream layout. Nine 1:1 module moves (reviewer_diff/eval_store/
findings/groups/publish/reconcile/trace_context + review_style_
collector/guidance) into agent/review/, with internal relative
imports re-wired to the new package depth. agent/review/__init__.py
mirrors upstream's thin re-export shim (one of the 21 verified "A"
structural adds).
Rewrote the 38 grep hits across importer files (agent/{analyzer,
ci_autofix,reviewer,webapp}.py, agent/dashboard/*, agent/middleware/
settle_review_check.py, agent/tools/*, agent/utils/github_feedback.py,
agent/webhooks/github.py, evals/reviewer/*, and the reviewer test
suite) to point at agent.review.*; 4 of the 38 hits were name
collisions (list_reviewer_findings, reviewer_outcomes,
_reviewer_thread_id, reviewer_thread_id — not the moved modules) and
were left untouched. tests/test_github_checks.py's module-alias
import (`from agent import reviewer_publish`) follows upstream's own
`from agent.review import publish as reviewer_publish` pattern so
downstream `reviewer_publish.*` call sites needed no changes.
agent/reviewer.py and agent/webapp.py stay in place per the hard
rule (fork content, import-only rewire) and are not part of this
package.
Gates: ruff check + ruff format --check, pytest --co -q (1637
collected), full unit suite (1637 passed), and the reviewer/findings
suite in isolation (pytest -k "review or finding", 421 passed).
250 lines
7.7 KiB
Python
250 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from langgraph_sdk import get_client
|
|
from langgraph_sdk.client import LangGraphClient
|
|
|
|
from ..review.findings import list_findings
|
|
from .langsmith import create_langsmith_feedback, delete_langsmith_feedback
|
|
from .reviewer_outcomes import outcome_from_score, upsert_finding_outcome
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
|
)
|
|
|
|
GITHUB_FEEDBACK_REACTIONS: dict[str, float] = {
|
|
"+1": 1.0,
|
|
"-1": 0.0,
|
|
}
|
|
|
|
_REACTION_STATE_NAMESPACE = "github_reaction_state"
|
|
_REACTION_EVENT_NAMESPACE = "github_reaction_events"
|
|
_PULL_URL_RE = re.compile(r"/pulls/(\d+)\Z")
|
|
|
|
|
|
def _reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str:
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{owner}/{repo}/pr/{pr_number}/reviewer"))
|
|
|
|
|
|
def _read_active_reactions(item: dict[str, Any] | None) -> set[str]:
|
|
if not item:
|
|
return set()
|
|
value = item.get("value")
|
|
if not isinstance(value, dict):
|
|
return set()
|
|
reactions = value.get("reactions")
|
|
if not isinstance(reactions, list):
|
|
return set()
|
|
return {reaction for reaction in reactions if isinstance(reaction, str)}
|
|
|
|
|
|
def _reaction_state_key(run_id: str, user_login: str, comment_id: int) -> str:
|
|
return f"{run_id}:{user_login}:{comment_id}"
|
|
|
|
|
|
def _feedback_key(owner: str, repo: str, user_login: str, comment_id: int) -> str:
|
|
return f"github_reaction:{owner}/{repo}:{user_login}:{comment_id}"
|
|
|
|
|
|
def _score_reactions(reactions: set[str]) -> float | None:
|
|
scores = {
|
|
GITHUB_FEEDBACK_REACTIONS[reaction]
|
|
for reaction in reactions
|
|
if reaction in GITHUB_FEEDBACK_REACTIONS
|
|
}
|
|
if len(scores) != 1:
|
|
return None
|
|
return next(iter(scores))
|
|
|
|
|
|
def _extract_pr_number(payload: dict[str, Any]) -> int | None:
|
|
pull_request = payload.get("pull_request")
|
|
if isinstance(pull_request, dict) and isinstance(pull_request.get("number"), int):
|
|
return pull_request["number"]
|
|
|
|
comment = payload.get("comment")
|
|
if isinstance(comment, dict):
|
|
url = comment.get("pull_request_url")
|
|
if isinstance(url, str):
|
|
match = _PULL_URL_RE.search(url)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
async def _event_was_processed(
|
|
langgraph_client: LangGraphClient, repo_key: str, event_id: str
|
|
) -> bool:
|
|
if not event_id:
|
|
return False
|
|
item = await langgraph_client.store.get_item((_REACTION_EVENT_NAMESPACE, repo_key), event_id)
|
|
return bool(item)
|
|
|
|
|
|
async def _mark_event_processed(
|
|
langgraph_client: LangGraphClient, repo_key: str, event_id: str
|
|
) -> None:
|
|
if not event_id:
|
|
return
|
|
await langgraph_client.store.put_item(
|
|
(_REACTION_EVENT_NAMESPACE, repo_key), event_id, {"event_id": event_id}
|
|
)
|
|
|
|
|
|
async def _update_reaction_state(
|
|
langgraph_client: LangGraphClient,
|
|
*,
|
|
repo_key: str,
|
|
run_id: str,
|
|
user_login: str,
|
|
comment_id: int,
|
|
reaction: str,
|
|
added: bool,
|
|
) -> set[str]:
|
|
namespace = (_REACTION_STATE_NAMESPACE, repo_key)
|
|
key = _reaction_state_key(run_id, user_login, comment_id)
|
|
item = await langgraph_client.store.get_item(namespace, key)
|
|
active_reactions = _read_active_reactions(item)
|
|
if added:
|
|
active_reactions.add(reaction)
|
|
else:
|
|
active_reactions.discard(reaction)
|
|
if not active_reactions:
|
|
await langgraph_client.store.delete_item(namespace, key)
|
|
return active_reactions
|
|
await langgraph_client.store.put_item(
|
|
namespace,
|
|
key,
|
|
{
|
|
"run_id": run_id,
|
|
"user_login": user_login,
|
|
"comment_id": comment_id,
|
|
"reactions": sorted(active_reactions),
|
|
},
|
|
)
|
|
return active_reactions
|
|
|
|
|
|
async def process_github_reaction(
|
|
payload: dict[str, Any],
|
|
*,
|
|
delivery_id: str = "",
|
|
added: bool,
|
|
) -> None:
|
|
reaction = payload.get("reaction")
|
|
content = reaction.get("content") if isinstance(reaction, dict) else None
|
|
if not isinstance(content, str) or content not in GITHUB_FEEDBACK_REACTIONS:
|
|
return
|
|
|
|
comment = payload.get("comment")
|
|
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
|
if not isinstance(comment_id, int):
|
|
return
|
|
|
|
repo = payload.get("repository")
|
|
owner = repo.get("owner", {}).get("login") if isinstance(repo, dict) else None
|
|
repo_name = repo.get("name") if isinstance(repo, dict) else None
|
|
pr_number = _extract_pr_number(payload)
|
|
sender = payload.get("sender")
|
|
user_login = sender.get("login") if isinstance(sender, dict) else None
|
|
if not (
|
|
isinstance(owner, str)
|
|
and owner
|
|
and isinstance(repo_name, str)
|
|
and repo_name
|
|
and isinstance(pr_number, int)
|
|
and isinstance(user_login, str)
|
|
and user_login
|
|
):
|
|
return
|
|
|
|
langgraph_client = get_client(url=LANGGRAPH_URL)
|
|
repo_key = f"{owner}/{repo_name}"
|
|
if await _event_was_processed(langgraph_client, repo_key, delivery_id):
|
|
return
|
|
|
|
thread_id = _reviewer_thread_id(owner, repo_name, pr_number)
|
|
findings = await list_findings(thread_id)
|
|
finding = next(
|
|
(
|
|
candidate
|
|
for candidate in findings
|
|
if candidate.get("github_review_comment_id") == comment_id
|
|
),
|
|
None,
|
|
)
|
|
if finding is None:
|
|
logger.debug("No tracked finding for GitHub review comment id %s", comment_id)
|
|
return
|
|
|
|
run_id = finding.get("github_review_run_id")
|
|
if not isinstance(run_id, str) or not run_id:
|
|
logger.debug("Finding %s has no LangSmith run id for feedback", finding.get("id"))
|
|
return
|
|
|
|
active_reactions = await _update_reaction_state(
|
|
langgraph_client,
|
|
repo_key=repo_key,
|
|
run_id=run_id,
|
|
user_login=user_login,
|
|
comment_id=comment_id,
|
|
reaction=content,
|
|
added=added,
|
|
)
|
|
|
|
key = _feedback_key(owner, repo_name, user_login, comment_id)
|
|
source_info = {
|
|
"source": "github_review_reaction",
|
|
"owner": owner,
|
|
"repo": repo_name,
|
|
"pr_number": pr_number,
|
|
"comment_id": comment_id,
|
|
"finding_id": finding.get("id"),
|
|
"user_login": user_login,
|
|
}
|
|
score = _score_reactions(active_reactions)
|
|
if score is None:
|
|
success = await asyncio.to_thread(delete_langsmith_feedback, run_id, key)
|
|
else:
|
|
success = await asyncio.to_thread(
|
|
create_langsmith_feedback,
|
|
run_id,
|
|
key,
|
|
score=score,
|
|
comment=f"GitHub review reaction feedback from {user_login}",
|
|
source_info={**source_info, "reactions": sorted(active_reactions)},
|
|
)
|
|
outcome = outcome_from_score(score, source="github")
|
|
if outcome is not None:
|
|
label, label_source = outcome
|
|
await asyncio.to_thread(
|
|
upsert_finding_outcome,
|
|
finding,
|
|
label=label,
|
|
label_source=label_source,
|
|
repo=repo_key,
|
|
pr_number=pr_number,
|
|
pr_url=f"https://github.com/{repo_key}/pull/{pr_number}",
|
|
head_sha=str(finding.get("first_seen_sha") or ""),
|
|
run_id=run_id,
|
|
thread_id=thread_id,
|
|
)
|
|
|
|
if success:
|
|
await _mark_event_processed(langgraph_client, repo_key, delivery_id)
|
|
|
|
|
|
async def process_github_reaction_added(payload: dict[str, Any], delivery_id: str = "") -> None:
|
|
await process_github_reaction(payload, delivery_id=delivery_id, added=True)
|
|
|
|
|
|
async def process_github_reaction_removed(payload: dict[str, Any], delivery_id: str = "") -> None:
|
|
await process_github_reaction(payload, delivery_id=delivery_id, added=False)
|