mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
* chore: include ripgrep in sandbox image (#1728) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 129ddcf9a2fe8b3710535eb10098f80c98b47690) * feat: include Cargo in sandbox image (#1729) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 1ea03a4330cc9faac65157f3fb94842d074e0231) * fix: prefer LangSmith tools for trace links (#1751) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit c69459adcafad4a59028232514cecb3bdaf0dfc0) * fix: add trace link to error banner (#1750) Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 5cb2e2bb3582d69241b386bb0852c6f6b40b2dbb) * fix: link issue PRs and prompt repo conventions (#1704) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 22e024cb1ca080e233eb2cc164767446173a7870) * chore: reconcile triage ledger --------- Co-authored-by: Johannes du Plessis <johannes@langchain.dev> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev> Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: Palash Shah <35114859+Palashio@users.noreply.github.com>
1231 lines
47 KiB
Python
1231 lines
47 KiB
Python
"""GitHub webhook handlers — moved out of webapp.py (behavior-identical).
|
|
|
|
Helpers and constants stay in webapp.py; they are accessed through the module
|
|
object (``webapp.X``) so tests that monkeypatch them keep working.
|
|
"""
|
|
|
|
import re
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from agent import webapp
|
|
|
|
from ..ci_autofix import handle_ci_failure, handle_review_feedback
|
|
from ..dashboard.autofix_state import set_pr_autofix_disabled
|
|
from ..reviewer_findings import FindingInteraction, ReviewerPRMeta, ReviewerSlackThread
|
|
from ..utils.github_ci import (
|
|
branch_from_check_payload,
|
|
head_sha_from_check_payload,
|
|
is_failing_ci_payload,
|
|
)
|
|
from ..utils.github_comments import GitHubAuthError
|
|
from ..utils.slack import GitHubPrRef
|
|
|
|
|
|
def build_github_issue_prompt(
|
|
repo_config: dict[str, str],
|
|
issue_number: int,
|
|
issue_id: str,
|
|
title: str,
|
|
body: str,
|
|
comments: list[dict[str, Any]],
|
|
*,
|
|
github_login: str,
|
|
issue_author: str = "",
|
|
issue_url: str = "",
|
|
) -> str:
|
|
"""Build the user prompt for a GitHub issue-triggered run."""
|
|
triggered_by_line = f"## Triggered by: {github_login}\n\n" if github_login else ""
|
|
issue_url_line = f"## Issue URL: {issue_url}\n\n" if issue_url else ""
|
|
comments_text = webapp._build_github_issue_comments_text(comments)
|
|
sanitized_title = webapp.sanitize_github_comment_body(title)
|
|
formatted_body = webapp.format_github_comment_body_for_prompt(
|
|
issue_author or github_login, body
|
|
)
|
|
return (
|
|
"Please work on the following GitHub issue:\n\n"
|
|
f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
|
|
f"{triggered_by_line}"
|
|
f"## GitHub Issue: #{issue_number} - Issue ID: {issue_id}\n\n"
|
|
f"{issue_url_line}"
|
|
f"## Title: {sanitized_title}\n\n"
|
|
f"## Description:\n{formatted_body}\n"
|
|
f"{comments_text}\n\n"
|
|
"Please analyze this issue and implement the necessary changes. "
|
|
"If you open a PR for this issue, make sure the PR description links back to "
|
|
"this issue and follows this repository's PR conventions for the title, body, "
|
|
"release note, and/or changelog. Inspect AGENTS.md, PR templates, "
|
|
".changelog/README.md, and nearby docs before choosing the PR title/body format. "
|
|
"When you need to communicate on GitHub, use `GH_TOKEN=dummy gh issue comment` "
|
|
"with the issue number."
|
|
)
|
|
|
|
|
|
def build_github_issue_followup_prompt(github_login: str, comment_body: str) -> str:
|
|
"""Build the prompt for a follow-up GitHub issue comment."""
|
|
return f"**{github_login}:**\n{webapp.format_github_comment_body_for_prompt(github_login, comment_body)}"
|
|
|
|
|
|
def build_github_issue_update_prompt(github_login: str, title: str, body: str) -> str:
|
|
"""Build the prompt for a follow-up GitHub issue title/body update."""
|
|
sanitized_title = webapp.sanitize_github_comment_body(title)
|
|
formatted_body = webapp.format_github_comment_body_for_prompt(github_login, body)
|
|
return (
|
|
f"**{github_login}:** updated the GitHub issue title/body.\n\n"
|
|
f"Title: {sanitized_title}\n\n"
|
|
f"Description:\n{formatted_body}"
|
|
)
|
|
|
|
|
|
def build_github_pr_review_prompt(
|
|
repo_config: dict[str, str],
|
|
pr_number: int,
|
|
pr_url: str,
|
|
base_sha: str,
|
|
head_sha: str,
|
|
) -> str:
|
|
"""Build the user prompt for a reviewer-agent run."""
|
|
return (
|
|
"Please review this GitHub pull request.\n\n"
|
|
f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
|
|
f"## Pull Request: {pr_url}\n\n"
|
|
f"## PR Number: {pr_number}\n\n"
|
|
f"## Base SHA: {base_sha}\n\n"
|
|
f"## Head SHA: {head_sha}\n\n"
|
|
"Submit findings as inline GitHub review comments. If there are no real issues, "
|
|
"submit no comments."
|
|
)
|
|
|
|
|
|
async def trigger_pr_review_from_ref(
|
|
pr_ref: GitHubPrRef,
|
|
*,
|
|
source: str,
|
|
github_login: str = "",
|
|
github_user_id: int | None = None,
|
|
slack_channel_id: str = "",
|
|
slack_thread_ts: str = "",
|
|
) -> dict[str, Any]:
|
|
repo_config = {"owner": pr_ref.owner, "name": pr_ref.repo}
|
|
if not await webapp._is_repo_enabled_for_review(repo_config):
|
|
return {"success": False, "error": "Repository not enabled for review"}
|
|
|
|
# Full token to read PR metadata (privacy/id aren't in the trigger ref);
|
|
# re-scoped below once we know whether the repo is public.
|
|
app_token, app_token_expires_at = await webapp.get_github_app_installation_token_with_expiry()
|
|
if not app_token:
|
|
webapp.logger.warning("No GitHub App token available for PR reviewer request")
|
|
return {"success": False, "error": "No GitHub App token available"}
|
|
|
|
pr_metadata = await webapp.fetch_github_pr_metadata(pr_ref, token=app_token)
|
|
if not pr_metadata:
|
|
return {"success": False, "error": "Could not fetch pull request metadata"}
|
|
|
|
repo_private = webapp._repo_private_from_pr_metadata(pr_metadata)
|
|
repo_id = webapp._repo_id_from_pr_metadata(pr_metadata)
|
|
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
|
repo_config,
|
|
repo_private=repo_private,
|
|
repo_id=repo_id,
|
|
)
|
|
if not app_token:
|
|
webapp.logger.warning("No GitHub App token available for PR reviewer request")
|
|
return {"success": False, "error": "No GitHub App token available"}
|
|
|
|
base_sha = pr_metadata.get("base", {}).get("sha", "")
|
|
head = pr_metadata.get("head", {})
|
|
head_sha = head.get("sha", "")
|
|
branch_name = head.get("ref", "")
|
|
base_ref = pr_metadata.get("base", {}).get("ref", "")
|
|
pr_title = pr_metadata.get("title", "")
|
|
pr_url = pr_metadata.get("html_url", "") or pr_ref.url
|
|
if not base_sha or not head_sha:
|
|
webapp.logger.warning("Missing base/head SHA for Slack PR review request")
|
|
return {"success": False, "error": "Pull request metadata is missing base/head SHA"}
|
|
|
|
thread_id = webapp.generate_reviewer_thread_id(pr_ref.owner, pr_ref.repo, pr_ref.number)
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
|
return {"success": False, "error": "Could not create reviewer thread"}
|
|
|
|
pr_meta: ReviewerPRMeta = {
|
|
"owner": pr_ref.owner,
|
|
"name": pr_ref.repo,
|
|
"number": pr_ref.number,
|
|
"url": pr_url,
|
|
"title": pr_title,
|
|
"head_ref": branch_name,
|
|
"base_ref": base_ref,
|
|
"author": (pr_metadata.get("user") or {}).get("login", ""),
|
|
}
|
|
slack_thread_meta: ReviewerSlackThread | None = None
|
|
if slack_channel_id and slack_thread_ts:
|
|
slack_thread_meta = {
|
|
"channel_id": slack_channel_id,
|
|
"thread_ts": slack_thread_ts,
|
|
}
|
|
await webapp.set_reviewer_thread_metadata(
|
|
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta, head_sha=head_sha
|
|
)
|
|
await webapp.post_review_started_comment(
|
|
thread_id=thread_id,
|
|
owner=pr_ref.owner,
|
|
repo=pr_ref.repo,
|
|
pr_number=pr_ref.number,
|
|
token=app_token,
|
|
)
|
|
|
|
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
|
|
configurable = webapp._build_reviewer_configurable(
|
|
source=source,
|
|
github_login=github_login,
|
|
github_user_id=github_user_id,
|
|
repo_config=repo_config,
|
|
pr_number=pr_ref.number,
|
|
pr_url=pr_url,
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
branch_name=branch_name,
|
|
repo_private=repo_private,
|
|
slack_channel_id=slack_channel_id,
|
|
slack_thread_ts=slack_thread_ts,
|
|
)
|
|
|
|
webapp.logger.info(
|
|
"Dispatching reviewer run for thread %s from %s PR review request", thread_id, source
|
|
)
|
|
run = await webapp.dispatch_agent_run(
|
|
thread_id,
|
|
prompt,
|
|
configurable,
|
|
source=source,
|
|
assistant_id="reviewer",
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
|
client=langgraph_client,
|
|
)
|
|
await webapp._store_current_reviewer_run_id(thread_id, run)
|
|
return {"success": True, "queued": False, "thread_id": thread_id, "pr_url": pr_url}
|
|
|
|
|
|
async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, source: str) -> None:
|
|
"""Trigger a first-review run on the canonical reviewer thread for a PR."""
|
|
repo = payload.get("repository", {})
|
|
pull_request = payload.get("pull_request", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
repo_private = webapp._repo_private_from_payload(payload)
|
|
repo_id = webapp._repo_id_from_payload(payload)
|
|
pr_number = pull_request.get("number")
|
|
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
|
branch_name = pull_request.get("head", {}).get("ref", "")
|
|
base_ref = pull_request.get("base", {}).get("ref", "")
|
|
base_sha = pull_request.get("base", {}).get("sha", "")
|
|
head_sha = pull_request.get("head", {}).get("sha", "")
|
|
pr_title = pull_request.get("title", "")
|
|
github_login = payload.get("sender", {}).get("login", "")
|
|
github_user_id = payload.get("sender", {}).get("id")
|
|
|
|
if not pr_number or not pr_url or not base_sha or not head_sha:
|
|
webapp.logger.warning("Missing PR context for reviewer dispatch, skipping run")
|
|
return
|
|
|
|
thread_id = webapp.generate_reviewer_thread_id(
|
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
|
)
|
|
|
|
pr_meta: ReviewerPRMeta = {
|
|
"owner": repo_config.get("owner", ""),
|
|
"name": repo_config.get("name", ""),
|
|
"number": pr_number,
|
|
"url": pr_url,
|
|
"title": pr_title,
|
|
"head_ref": branch_name,
|
|
"base_ref": base_ref,
|
|
"author": (pull_request.get("user") or {}).get("login", ""),
|
|
}
|
|
last_reviewed_sha = ""
|
|
if payload.get("action") == "ready_for_review":
|
|
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
|
if metadata is not None and metadata.get("kind") == webapp.REVIEWER_THREAD_KIND:
|
|
existing_last_reviewed_sha = metadata.get("last_reviewed_sha")
|
|
if isinstance(existing_last_reviewed_sha, str) and existing_last_reviewed_sha:
|
|
if existing_last_reviewed_sha == head_sha:
|
|
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
|
|
webapp.logger.info(
|
|
"Skipping ready_for_review auto-review for %s/%s#%s: "
|
|
"head_sha unchanged from last_reviewed_sha",
|
|
repo_config.get("owner"),
|
|
repo_config.get("name"),
|
|
pr_number,
|
|
)
|
|
return
|
|
last_reviewed_sha = existing_last_reviewed_sha
|
|
|
|
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
|
repo_config,
|
|
repo_private=repo_private,
|
|
repo_id=repo_id,
|
|
)
|
|
if not app_token:
|
|
webapp.logger.warning("No GitHub App token available for reviewer dispatch")
|
|
return
|
|
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
|
return
|
|
|
|
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
|
|
|
check_run_id = await webapp.create_review_check_run(
|
|
owner=repo_config.get("owner", ""),
|
|
repo=repo_config.get("name", ""),
|
|
head_sha=head_sha,
|
|
token=app_token,
|
|
details_url=webapp.dashboard_thread_url(thread_id),
|
|
)
|
|
if check_run_id is not None:
|
|
await webapp.set_reviewer_thread_metadata(
|
|
thread_id, extra={"review_check_run_id": check_run_id}
|
|
)
|
|
|
|
is_re_review = bool(last_reviewed_sha)
|
|
if is_re_review:
|
|
prompt = (
|
|
f"PR #{pr_number} has been marked ready for review. The new HEAD is "
|
|
f"{head_sha}. Reconcile existing findings against the new diff, add any "
|
|
f"net-new findings, and call `publish_review` once you're done."
|
|
)
|
|
else:
|
|
prompt = build_github_pr_review_prompt(repo_config, pr_number, pr_url, base_sha, head_sha)
|
|
configurable = webapp._build_reviewer_configurable(
|
|
source=source,
|
|
github_login=github_login,
|
|
github_user_id=github_user_id,
|
|
repo_config=repo_config,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url,
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
branch_name=branch_name,
|
|
repo_private=repo_private,
|
|
re_review=is_re_review,
|
|
last_reviewed_sha=last_reviewed_sha,
|
|
)
|
|
|
|
webapp.logger.info("Dispatching reviewer run for thread %s (source=%s)", thread_id, source)
|
|
run = await webapp.dispatch_agent_run(
|
|
thread_id,
|
|
prompt,
|
|
configurable,
|
|
source=source,
|
|
assistant_id="reviewer",
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
|
client=langgraph_client,
|
|
)
|
|
await webapp._store_current_reviewer_run_id(thread_id, run)
|
|
webapp.logger.info("Reviewer run dispatched for thread %s (source=%s)", thread_id, source)
|
|
|
|
|
|
async def process_github_pr_ready(payload: dict[str, Any]) -> None:
|
|
"""Auto-review a PR that has just been opened or marked ready-for-review.
|
|
|
|
Drafts are gated by the PR author's ``review_draft_prs`` profile flag
|
|
(with the team-wide setting as a fallback).
|
|
"""
|
|
pull_request = payload.get("pull_request", {})
|
|
is_draft = bool(pull_request.get("draft"))
|
|
if is_draft:
|
|
author = pull_request.get("user") or {}
|
|
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
|
if not await webapp._draft_review_enabled_for_author(author_login):
|
|
webapp.logger.info(
|
|
"Skipping auto-review of draft PR by %s: review_draft_prs is disabled",
|
|
author_login or "<unknown>",
|
|
)
|
|
return
|
|
# Use source="github" so the reviewer resolver can use the GitHub App token;
|
|
# "github_auto" would fall through to the email-based path, which has no
|
|
# user_email to route on for webhook-triggered runs.
|
|
await _dispatch_first_review_from_pr_payload(payload, source="github")
|
|
|
|
|
|
async def process_github_pr_close(payload: dict[str, Any]) -> None:
|
|
"""Toggle watch on the canonical reviewer thread on close/reopen/draft transitions.
|
|
|
|
``reopened`` re-enables watch; ``closed`` always disables it.
|
|
``converted_to_draft`` disables watch only when the PR author's effective
|
|
draft-review setting is off — if drafts should be reviewed, watch stays on
|
|
so subsequent pushes still trigger re-reviews while the PR is in draft.
|
|
"""
|
|
repo = payload.get("repository", {})
|
|
pull_request = payload.get("pull_request", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
pr_number = pull_request.get("number")
|
|
if not pr_number or not isinstance(pr_number, int):
|
|
return
|
|
if not await webapp._is_repo_enabled_for_review(repo_config):
|
|
return
|
|
|
|
thread_id = webapp.generate_reviewer_thread_id(
|
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
|
)
|
|
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
|
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
|
# No reviewer thread for this PR, nothing to do.
|
|
webapp.logger.debug(
|
|
"PR %s/%s#%s closed/reopened: no reviewer thread, skipping watch update",
|
|
repo_config.get("owner"),
|
|
repo_config.get("name"),
|
|
pr_number,
|
|
)
|
|
return
|
|
action = payload.get("action", "")
|
|
if action == "converted_to_draft":
|
|
author = pull_request.get("user") or {}
|
|
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
|
if await webapp._draft_review_enabled_for_author(author_login):
|
|
webapp.logger.info(
|
|
"PR %s/%s#%s converted to draft but author %s has draft reviews enabled; keeping watch",
|
|
repo_config.get("owner"),
|
|
repo_config.get("name"),
|
|
pr_number,
|
|
author_login or "<unknown>",
|
|
)
|
|
return
|
|
desired_watch = False
|
|
else:
|
|
desired_watch = action == "reopened"
|
|
if metadata.get("watch") == desired_watch:
|
|
return
|
|
await webapp.set_reviewer_thread_metadata(thread_id, watch=desired_watch)
|
|
webapp.logger.info(
|
|
"Set watch=%s on reviewer thread %s after PR %s", desired_watch, thread_id, action
|
|
)
|
|
|
|
|
|
async def process_github_push_event(payload: dict[str, Any]) -> None:
|
|
"""Re-trigger the reviewer for a watched PR when its head branch is pushed to."""
|
|
ref = payload.get("ref", "")
|
|
after_sha = payload.get("after", "")
|
|
if not ref.startswith("refs/heads/"):
|
|
webapp.logger.debug("Push ignored: ref %s is not a branch", ref)
|
|
return
|
|
if not isinstance(after_sha, str) or not after_sha or set(after_sha) == {"0"}:
|
|
webapp.logger.debug("Push to %s ignored: branch deletion or missing SHA", ref)
|
|
return
|
|
head_ref = ref[len("refs/heads/") :]
|
|
|
|
repo = payload.get("repository", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", "") or repo.get("owner", {}).get("name", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
repo_private = webapp._repo_private_from_payload(payload)
|
|
repo_id = webapp._repo_id_from_payload(payload)
|
|
if not repo_config["owner"] or not repo_config["name"]:
|
|
webapp.logger.warning(
|
|
"Push to %s ignored: repository owner/name missing from payload", head_ref
|
|
)
|
|
return
|
|
if not await webapp._is_repo_enabled_for_review(repo_config):
|
|
webapp.logger.info(
|
|
"Push to %s/%s head=%s ignored: repo not enabled for review",
|
|
repo_config["owner"],
|
|
repo_config["name"],
|
|
head_ref,
|
|
)
|
|
return
|
|
|
|
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
|
repo_config,
|
|
repo_private=repo_private,
|
|
repo_id=repo_id,
|
|
)
|
|
if not app_token:
|
|
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
|
return
|
|
|
|
pr = await webapp._fetch_open_pr_for_branch(repo_config, head_ref, token=app_token)
|
|
if not pr:
|
|
webapp.logger.debug(
|
|
"No open PR found for push to %s/%s head=%s",
|
|
repo_config["owner"],
|
|
repo_config["name"],
|
|
head_ref,
|
|
)
|
|
return
|
|
|
|
# Push payloads normally carry repo privacy/id; fall back to PR metadata.
|
|
# If the repo turns out public, re-scope the token so reviewer.py doesn't
|
|
# proxy a full-installation token for a public PR.
|
|
if repo_private is None:
|
|
repo_private = webapp._repo_private_from_pr_metadata(pr)
|
|
repo_id = repo_id or webapp._repo_id_from_pr_metadata(pr)
|
|
if repo_private is False:
|
|
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
|
repo_config,
|
|
repo_private=repo_private,
|
|
repo_id=repo_id,
|
|
)
|
|
if not app_token:
|
|
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
|
return
|
|
pr_number = pr.get("number")
|
|
pr_url = pr.get("html_url") or pr.get("url") or ""
|
|
base_sha = pr.get("base", {}).get("sha", "")
|
|
base_ref = pr.get("base", {}).get("ref", "")
|
|
head_sha = pr.get("head", {}).get("sha", after_sha)
|
|
pr_title = pr.get("title", "")
|
|
if not isinstance(pr_number, int) or not base_sha or not head_sha:
|
|
webapp.logger.warning(
|
|
"Push to %s/%s head=%s ignored: PR metadata missing number/base/head SHA",
|
|
repo_config["owner"],
|
|
repo_config["name"],
|
|
head_ref,
|
|
)
|
|
return
|
|
|
|
thread_id = webapp.generate_reviewer_thread_id(
|
|
repo_config["owner"], repo_config["name"], pr_number
|
|
)
|
|
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
|
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
|
webapp.logger.info(
|
|
"Push to %s/%s#%s ignored: no reviewer thread for this PR. "
|
|
"Trigger a first review (Slack `@open-swe review <url>` or request "
|
|
"open-swe[bot] as a GitHub reviewer) to start watching.",
|
|
repo_config["owner"],
|
|
repo_config["name"],
|
|
pr_number,
|
|
)
|
|
return
|
|
if not metadata.get("watch"):
|
|
webapp.logger.info(
|
|
"Push to %s ignored: reviewer thread %s is not watching", head_ref, thread_id
|
|
)
|
|
return
|
|
|
|
last_reviewed_sha = metadata.get("last_reviewed_sha")
|
|
if isinstance(last_reviewed_sha, str) and last_reviewed_sha == head_sha:
|
|
webapp.logger.info(
|
|
"Push to %s ignored: head_sha unchanged from last_reviewed_sha", head_ref
|
|
)
|
|
return
|
|
if (
|
|
isinstance(last_reviewed_sha, str)
|
|
and last_reviewed_sha
|
|
and await webapp._is_pr_diff_unchanged_since_last_review(
|
|
repo_config,
|
|
base_ref=base_ref,
|
|
last_reviewed_sha=last_reviewed_sha,
|
|
head_sha=head_sha,
|
|
token=app_token,
|
|
)
|
|
):
|
|
await webapp.set_reviewer_thread_metadata(thread_id, last_reviewed_sha=head_sha)
|
|
# The old head's check disappears once the head moves (GitHub only
|
|
# shows checks on the current head), so even though no re-review runs,
|
|
# surface a settled check on the new head.
|
|
unchanged_check_id = await webapp.create_review_check_run(
|
|
owner=repo_config["owner"],
|
|
repo=repo_config["name"],
|
|
head_sha=head_sha,
|
|
token=app_token,
|
|
details_url=webapp.dashboard_thread_url(thread_id),
|
|
)
|
|
if unchanged_check_id is not None:
|
|
await webapp.complete_review_check_run(
|
|
owner=repo_config["owner"],
|
|
repo=repo_config["name"],
|
|
check_run_id=unchanged_check_id,
|
|
token=app_token,
|
|
conclusion="success",
|
|
title="No new changes to review",
|
|
summary=(
|
|
"The pull request diff is unchanged since the last reviewed "
|
|
f"commit {last_reviewed_sha}."
|
|
),
|
|
)
|
|
webapp.logger.info(
|
|
"Push to %s ignored: PR diff unchanged since last reviewed SHA %s",
|
|
head_ref,
|
|
last_reviewed_sha,
|
|
)
|
|
return
|
|
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
|
return
|
|
try:
|
|
threads = await webapp.fetch_pr_review_threads(
|
|
owner=repo_config["owner"],
|
|
repo=repo_config["name"],
|
|
pr_number=pr_number,
|
|
token=app_token,
|
|
)
|
|
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
|
|
except Exception:
|
|
webapp.logger.warning(
|
|
"Could not sync review threads before push re-review for %s", thread_id
|
|
)
|
|
|
|
pr_meta: ReviewerPRMeta = {
|
|
"owner": repo_config["owner"],
|
|
"name": repo_config["name"],
|
|
"number": pr_number,
|
|
"url": pr_url,
|
|
"title": pr_title,
|
|
"head_ref": head_ref,
|
|
"base_ref": base_ref,
|
|
"author": (pr.get("user") or {}).get("login", ""),
|
|
}
|
|
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
|
|
|
# GitHub only shows check runs on a PR's current head commit, so the check
|
|
# created on the previous head disappears after a follow-up push. Create a
|
|
# fresh in-progress check on the new head SHA so the review stays visible;
|
|
# publish (or the after-agent hook) settles this id.
|
|
check_run_id = await webapp.create_review_check_run(
|
|
owner=repo_config["owner"],
|
|
repo=repo_config["name"],
|
|
head_sha=head_sha,
|
|
token=app_token,
|
|
details_url=webapp.dashboard_thread_url(thread_id),
|
|
)
|
|
if check_run_id is not None:
|
|
await webapp.set_reviewer_thread_metadata(
|
|
thread_id, extra={"review_check_run_id": check_run_id}
|
|
)
|
|
|
|
re_review_prompt = (
|
|
f"A new commit has been pushed to PR #{pr_number}. The new HEAD is "
|
|
f"{head_sha}. Reconcile existing findings against the new diff, add any "
|
|
f"net-new findings, and call `publish_review` once you're done."
|
|
)
|
|
configurable = webapp._build_reviewer_configurable(
|
|
source="github_push",
|
|
github_login=payload.get("sender", {}).get("login", "") or "",
|
|
github_user_id=payload.get("sender", {}).get("id"),
|
|
repo_config=repo_config,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url,
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
branch_name=head_ref,
|
|
repo_private=repo_private,
|
|
re_review=True,
|
|
last_reviewed_sha=last_reviewed_sha if isinstance(last_reviewed_sha, str) else "",
|
|
)
|
|
|
|
webapp.logger.info("Dispatching push re-review run for thread %s", thread_id)
|
|
run = await webapp.dispatch_agent_run(
|
|
thread_id,
|
|
re_review_prompt,
|
|
configurable,
|
|
source="github_push",
|
|
assistant_id="reviewer",
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
|
client=langgraph_client,
|
|
)
|
|
await webapp._store_current_reviewer_run_id(thread_id, run)
|
|
|
|
|
|
async def process_github_pr_comment(payload: dict[str, Any], event_type: str) -> None:
|
|
"""Process a GitHub PR comment that tagged @open-swe.
|
|
|
|
Retrieves the existing thread token, reacts with 👀, fetches all comments
|
|
since the last @open-swe tag, then creates or queues a new run.
|
|
|
|
Args:
|
|
payload: The parsed GitHub webhook payload.
|
|
event_type: One of 'issue_comment', 'pull_request_review_comment',
|
|
'pull_request_review'.
|
|
"""
|
|
(
|
|
repo_config,
|
|
pr_number,
|
|
branch_name,
|
|
github_login,
|
|
pr_url,
|
|
comment_id,
|
|
node_id,
|
|
) = await webapp.extract_pr_context(payload, event_type)
|
|
github_user_id = payload.get("sender", {}).get("id")
|
|
|
|
webapp.logger.info(
|
|
"Processing GitHub PR comment: event=%s, pr=%s, branch=%s",
|
|
event_type,
|
|
pr_number,
|
|
branch_name,
|
|
)
|
|
|
|
thread_id = webapp.get_thread_id_from_branch(branch_name) if branch_name else None
|
|
if not thread_id:
|
|
if not pr_number:
|
|
webapp.logger.warning(
|
|
"Could not determine thread_id for branch '%s' (no pr_number), skipping",
|
|
branch_name,
|
|
)
|
|
return
|
|
owner = repo_config.get("owner", "")
|
|
name = repo_config.get("name", "")
|
|
stable_key = f"{owner}/{name}/pr/{pr_number}"
|
|
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
|
webapp.logger.info(
|
|
"Generated thread_id %s for non-open-swe branch '%s'", thread_id, branch_name
|
|
)
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
try:
|
|
await langgraph_client.threads.update(thread_id, metadata={"branch_name": branch_name})
|
|
except Exception as exc: # noqa: BLE001
|
|
if webapp._is_not_found_error(exc):
|
|
await langgraph_client.threads.create(
|
|
thread_id=thread_id,
|
|
if_exists="do_nothing",
|
|
metadata={"branch_name": branch_name},
|
|
)
|
|
else:
|
|
webapp.logger.warning(
|
|
"Failed to persist branch_name metadata for thread %s", thread_id
|
|
)
|
|
|
|
# Refresh the per-process user-mapping cache from the Store before
|
|
# resolving the author's email. On a multi-replica managed deployment this
|
|
# replica's cache may be stale (a mapping created on another replica is not
|
|
# otherwise visible), which would drop a legitimately-mapped user. Mirrors
|
|
# the Slack mention path (process_slack_mention).
|
|
try:
|
|
await webapp.refresh_user_mapping_cache()
|
|
except Exception: # noqa: BLE001
|
|
webapp.logger.debug(
|
|
"Could not refresh user mapping cache for GitHub PR comment", exc_info=True
|
|
)
|
|
|
|
email = await webapp.email_for_login(github_login) or ""
|
|
if email:
|
|
github_token = await webapp._get_or_resolve_thread_github_token(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
else:
|
|
webapp.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
|
return
|
|
|
|
if not github_token:
|
|
webapp.logger.warning("No GitHub token for thread %s, skipping", thread_id)
|
|
return
|
|
|
|
if comment_id:
|
|
try:
|
|
await webapp.react_to_github_comment(
|
|
repo_config,
|
|
comment_id,
|
|
event_type=event_type,
|
|
token=github_token,
|
|
pull_number=pr_number,
|
|
node_id=node_id,
|
|
)
|
|
except GitHubAuthError:
|
|
github_token = await webapp._refresh_thread_github_token_after_401(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
if not github_token:
|
|
webapp.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
|
return
|
|
await webapp.react_to_github_comment(
|
|
repo_config,
|
|
comment_id,
|
|
event_type=event_type,
|
|
token=github_token,
|
|
pull_number=pr_number,
|
|
node_id=node_id,
|
|
)
|
|
|
|
if not pr_number:
|
|
webapp.logger.warning("No PR number found in payload, skipping")
|
|
return
|
|
|
|
try:
|
|
comments = await webapp.fetch_pr_comments_since_last_tag(
|
|
repo_config, pr_number, token=github_token
|
|
)
|
|
except GitHubAuthError:
|
|
github_token = await webapp._refresh_thread_github_token_after_401(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
if not github_token:
|
|
webapp.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
|
return
|
|
comments = await webapp.fetch_pr_comments_since_last_tag(
|
|
repo_config, pr_number, token=github_token
|
|
)
|
|
if not comments:
|
|
webapp.logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
|
|
return
|
|
|
|
prompt = webapp.build_pr_prompt(comments, pr_url, repo_config=repo_config)
|
|
await webapp._trigger_or_queue_run(
|
|
thread_id,
|
|
prompt,
|
|
github_login=github_login,
|
|
github_user_id=github_user_id,
|
|
repo_config=repo_config,
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
|
|
async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
|
"""Route replies to Open SWE review comments back to the reviewer graph."""
|
|
parent_comment_id = webapp._review_comment_reply_parent_id(payload)
|
|
if parent_comment_id is None:
|
|
return
|
|
|
|
sender = payload.get("sender", {})
|
|
sender_login = sender.get("login") if isinstance(sender, dict) else None
|
|
if sender_login in webapp.INTERNAL_BOT_LOGINS:
|
|
return
|
|
|
|
repo = payload.get("repository", {})
|
|
pull_request = payload.get("pull_request", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
repo_private = webapp._repo_private_from_payload(payload)
|
|
repo_id = webapp._repo_id_from_payload(payload)
|
|
pr_number = pull_request.get("number")
|
|
if not isinstance(pr_number, int):
|
|
return
|
|
|
|
thread_id = webapp.generate_reviewer_thread_id(
|
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
|
)
|
|
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
|
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
|
return
|
|
|
|
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
|
repo_config,
|
|
repo_private=repo_private,
|
|
repo_id=repo_id,
|
|
)
|
|
if not app_token:
|
|
return
|
|
|
|
threads = await webapp.fetch_pr_review_threads(
|
|
owner=repo_config["owner"],
|
|
repo=repo_config["name"],
|
|
pr_number=pr_number,
|
|
token=app_token,
|
|
)
|
|
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
|
|
findings = await webapp.list_reviewer_findings(thread_id)
|
|
finding = next(
|
|
(item for item in findings if parent_comment_id in webapp._finding_comment_ids(item)), None
|
|
)
|
|
if finding is None:
|
|
return
|
|
finding_id = finding.get("id")
|
|
if not isinstance(finding_id, str):
|
|
return
|
|
|
|
comment = payload.get("comment", {})
|
|
if not isinstance(comment, dict):
|
|
return
|
|
reply_body = comment.get("body") if isinstance(comment.get("body"), str) else ""
|
|
reply_author = sender_login if isinstance(sender_login, str) else "unknown"
|
|
reply_comment_id = comment.get("id") if isinstance(comment.get("id"), int) else None
|
|
interaction: FindingInteraction = {
|
|
"kind": "human_reply",
|
|
"github_comment_id": reply_comment_id,
|
|
"github_parent_comment_id": parent_comment_id,
|
|
"author": reply_author,
|
|
"body": reply_body,
|
|
"created_at": comment.get("created_at")
|
|
if isinstance(comment.get("created_at"), str)
|
|
else "",
|
|
"needs_reassessment": True,
|
|
}
|
|
await webapp.append_finding_interaction(thread_id, finding_id, interaction)
|
|
|
|
base_sha = pull_request.get("base", {}).get("sha", "")
|
|
head_sha = pull_request.get("head", {}).get("sha", "")
|
|
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
|
branch_name = pull_request.get("head", {}).get("ref", "")
|
|
configurable = webapp._build_reviewer_configurable(
|
|
source="github_review_comment",
|
|
github_login=reply_author,
|
|
github_user_id=sender.get("id") if isinstance(sender, dict) else None,
|
|
repo_config=repo_config,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url,
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
branch_name=branch_name,
|
|
repo_private=repo_private,
|
|
re_review=True,
|
|
)
|
|
configurable.update(
|
|
{
|
|
"reviewer_event": "finding_reply",
|
|
"finding_reply_id": finding_id,
|
|
"finding_reply_author": reply_author,
|
|
"finding_reply_body": reply_body,
|
|
}
|
|
)
|
|
finding_reply_prompt = webapp._build_queued_finding_reply_prompt(
|
|
finding_id=finding_id,
|
|
reply_author=reply_author,
|
|
reply_body=reply_body,
|
|
pr_number=pr_number,
|
|
)
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
run = await webapp.dispatch_agent_run(
|
|
thread_id,
|
|
finding_reply_prompt,
|
|
configurable,
|
|
source="github_review_reply",
|
|
assistant_id="reviewer",
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
|
client=langgraph_client,
|
|
)
|
|
await webapp._store_current_reviewer_run_id(thread_id, run)
|
|
|
|
|
|
async def process_github_issue(payload: dict[str, Any], event_type: str) -> None:
|
|
"""Process a GitHub issue or issue comment that tagged @open-swe."""
|
|
issue = payload.get("issue", {})
|
|
repo = payload.get("repository", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
|
|
issue_id = str(issue.get("id", ""))
|
|
issue_number = issue.get("number")
|
|
github_login = payload.get("sender", {}).get("login", "")
|
|
github_user_id = payload.get("sender", {}).get("id")
|
|
issue_url = issue.get("html_url", "") or issue.get("url", "")
|
|
title = issue.get("title", "No title")
|
|
description = issue.get("body") or "No description"
|
|
issue_author = issue.get("user", {}).get("login", "")
|
|
|
|
webapp.logger.info(
|
|
"Processing GitHub issue: event=%s, issue=%s, repo=%s/%s",
|
|
event_type,
|
|
issue_number,
|
|
repo_config.get("owner"),
|
|
repo_config.get("name"),
|
|
)
|
|
|
|
if not issue_id or not issue_number:
|
|
webapp.logger.warning("Missing GitHub issue id/number, skipping")
|
|
return
|
|
|
|
# Refresh the per-process user-mapping cache from the Store before
|
|
# resolving the author's email (multi-replica staleness; mirrors the Slack
|
|
# mention path in process_slack_mention).
|
|
try:
|
|
await webapp.refresh_user_mapping_cache()
|
|
except Exception: # noqa: BLE001
|
|
webapp.logger.debug("Could not refresh user mapping cache for GitHub issue", exc_info=True)
|
|
|
|
email = await webapp.email_for_login(github_login) or ""
|
|
if not email:
|
|
webapp.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
|
return
|
|
|
|
thread_id = webapp.generate_thread_id_from_github_issue(issue_id)
|
|
existing_thread = await webapp._thread_exists(thread_id)
|
|
github_token = await webapp._get_or_resolve_thread_github_token(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
app_token = await webapp.get_github_app_installation_token()
|
|
reaction_token = github_token or app_token
|
|
comment = payload.get("comment", {})
|
|
comment_id = comment.get("id")
|
|
if event_type == "issue_comment" and comment_id:
|
|
if not reaction_token:
|
|
webapp.logger.warning(
|
|
"No GitHub token available to react to issue comment %s", comment_id
|
|
)
|
|
else:
|
|
try:
|
|
reacted = await webapp.react_to_github_comment(
|
|
repo_config,
|
|
comment_id,
|
|
event_type="issue_comment",
|
|
token=reaction_token,
|
|
)
|
|
except GitHubAuthError:
|
|
github_token = await webapp._refresh_thread_github_token_after_401(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
reaction_token = github_token or app_token
|
|
reacted = False
|
|
if reaction_token:
|
|
try:
|
|
reacted = await webapp.react_to_github_comment(
|
|
repo_config,
|
|
comment_id,
|
|
event_type="issue_comment",
|
|
token=reaction_token,
|
|
)
|
|
except GitHubAuthError:
|
|
webapp.logger.warning(
|
|
"Re-auth still produced 401 reacting to issue comment %s",
|
|
comment_id,
|
|
)
|
|
reacted = False
|
|
if not reacted:
|
|
webapp.logger.warning("Failed to react to GitHub issue comment %s", comment_id)
|
|
|
|
if existing_thread:
|
|
if event_type == "issue_comment":
|
|
prompt = build_github_issue_followup_prompt(
|
|
comment.get("user", {}).get("login", github_login) or github_login,
|
|
comment.get("body", ""),
|
|
)
|
|
else:
|
|
prompt = build_github_issue_update_prompt(github_login, title, description)
|
|
else:
|
|
try:
|
|
comments = await webapp.fetch_issue_comments(
|
|
repo_config, issue_number, token=github_token or app_token
|
|
)
|
|
except GitHubAuthError:
|
|
github_token = await webapp._refresh_thread_github_token_after_401(
|
|
thread_id, email, repo=repo_config
|
|
)
|
|
comments = await webapp.fetch_issue_comments(
|
|
repo_config, issue_number, token=github_token or app_token
|
|
)
|
|
if comment_id and not any(item.get("comment_id") == comment_id for item in comments):
|
|
comments.append(
|
|
{
|
|
"body": comment.get("body", ""),
|
|
"author": comment.get("user", {}).get("login", "unknown"),
|
|
"created_at": comment.get("created_at", ""),
|
|
"comment_id": comment_id,
|
|
}
|
|
)
|
|
comments.sort(key=lambda item: item.get("created_at", ""))
|
|
|
|
prompt = build_github_issue_prompt(
|
|
repo_config,
|
|
issue_number,
|
|
issue_id,
|
|
title,
|
|
description,
|
|
comments,
|
|
github_login=github_login,
|
|
issue_author=issue_author,
|
|
issue_url=issue_url,
|
|
)
|
|
configurable: dict[str, Any] = {
|
|
"source": "github",
|
|
"github_login": github_login,
|
|
"github_user_id": github_user_id,
|
|
"repo": repo_config,
|
|
"github_issue": {
|
|
"id": issue_id,
|
|
"number": issue_number,
|
|
"title": title,
|
|
"url": issue_url,
|
|
},
|
|
}
|
|
|
|
await webapp.upsert_agent_thread_owner_metadata(
|
|
thread_id,
|
|
source="github",
|
|
repo_config=repo_config,
|
|
github_login=github_login,
|
|
title=title or (f"Issue #{issue_number}" if issue_number else ""),
|
|
source_context={"github_issue": configurable["github_issue"]},
|
|
)
|
|
|
|
webapp.logger.info("Dispatching LangGraph run for thread %s from GitHub issue", thread_id)
|
|
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
|
await webapp.dispatch_agent_run(
|
|
thread_id,
|
|
prompt,
|
|
configurable,
|
|
source="github_issue",
|
|
metadata=webapp._AGENT_VERSION_METADATA,
|
|
client=langgraph_client,
|
|
)
|
|
webapp.logger.info("LangGraph run dispatched for thread %s from GitHub issue", thread_id)
|
|
|
|
|
|
# ---- CI auto-fix handlers (re-wired onto the modular dispatch model) ----
|
|
|
|
|
|
async def process_github_ci_event(payload: dict[str, Any], event_type: str) -> None:
|
|
"""Auto-fix failing CI on an agent-authored PR from a CI webhook."""
|
|
if not is_failing_ci_payload(payload, event_type):
|
|
return
|
|
repo = payload.get("repository", {})
|
|
repo_config = {
|
|
"owner": repo.get("owner", {}).get("login", "") or repo.get("owner", {}).get("name", ""),
|
|
"name": repo.get("name", ""),
|
|
}
|
|
if not repo_config["owner"] or not repo_config["name"]:
|
|
return
|
|
branch = branch_from_check_payload(payload, event_type)
|
|
head_sha = head_sha_from_check_payload(payload, event_type)
|
|
if not head_sha:
|
|
return
|
|
result = await handle_ci_failure(
|
|
repo_config=repo_config,
|
|
branch=branch,
|
|
head_sha=head_sha,
|
|
source="github_ci",
|
|
)
|
|
webapp.logger.info(
|
|
"CI auto-fix for %s/%s@%s (%s): %s",
|
|
repo_config["owner"],
|
|
repo_config["name"],
|
|
head_sha,
|
|
event_type,
|
|
result,
|
|
)
|
|
|
|
|
|
_AUTOFIX_COMMAND_RE = re.compile(r"autofix\s+(on|off)\b", re.IGNORECASE)
|
|
|
|
|
|
def _parse_autofix_command(comment_body: str) -> bool | None:
|
|
"""Return True (disable) / False (enable) for an ``@open-swe autofix on|off`` command.
|
|
|
|
Returns ``None`` when the comment isn't an auto-fix command. Requires an
|
|
Open SWE mention so a passing reference to "autofix off" doesn't toggle it.
|
|
"""
|
|
if not any(tag in comment_body.lower() for tag in webapp.OPEN_SWE_TAGS):
|
|
return None
|
|
match = _AUTOFIX_COMMAND_RE.search(comment_body)
|
|
if not match:
|
|
return None
|
|
return match.group(1).lower() == "off"
|
|
|
|
|
|
def _pr_ref_from_comment_payload(payload: dict[str, Any], event_type: str) -> dict[str, Any] | None:
|
|
"""Extract ``{owner, name, number, url}`` for the PR a comment belongs to."""
|
|
repo = payload.get("repository", {})
|
|
owner = repo.get("owner", {}).get("login", "")
|
|
name = repo.get("name", "")
|
|
if event_type == "issue_comment":
|
|
issue = payload.get("issue", {})
|
|
number = issue.get("number")
|
|
pr = issue.get("pull_request") or {}
|
|
url = pr.get("html_url") or issue.get("html_url") or ""
|
|
else:
|
|
pr = payload.get("pull_request", {})
|
|
number = pr.get("number")
|
|
url = pr.get("html_url") or ""
|
|
if not owner or not name or not isinstance(number, int):
|
|
return None
|
|
return {"owner": owner, "name": name, "number": number, "url": url}
|
|
|
|
|
|
async def process_github_autofix_command(
|
|
payload: dict[str, Any], event_type: str, *, disabled: bool
|
|
) -> None:
|
|
"""Persist an ``@open-swe autofix on|off`` per-PR toggle and acknowledge it."""
|
|
ref = _pr_ref_from_comment_payload(payload, event_type)
|
|
if ref is None:
|
|
return
|
|
await set_pr_autofix_disabled(ref["owner"], ref["name"], ref["number"], disabled)
|
|
webapp.logger.info(
|
|
"Auto-fix %s for %s/%s#%s via comment",
|
|
"disabled" if disabled else "enabled",
|
|
ref["owner"],
|
|
ref["name"],
|
|
ref["number"],
|
|
)
|
|
comment = payload.get("comment") or {}
|
|
comment_id = comment.get("id")
|
|
if not isinstance(comment_id, int):
|
|
return
|
|
token = await webapp.get_github_app_installation_token()
|
|
if not token:
|
|
return
|
|
try:
|
|
await webapp.react_to_github_comment(
|
|
{"owner": ref["owner"], "name": ref["name"]},
|
|
comment_id,
|
|
event_type=event_type,
|
|
token=token,
|
|
pull_number=ref["number"],
|
|
node_id=comment.get("node_id"),
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
webapp.logger.debug("Failed to react to auto-fix command comment", exc_info=True)
|
|
|
|
|
|
# GitHub author_association values that imply at least repo-member trust. Used
|
|
# as a cheap first gate before the no-mention auto-fix-on-review path; a real
|
|
# write-permission check follows in process_github_autofix_review.
|
|
_TRUSTED_REVIEW_ASSOCIATIONS = frozenset(["OWNER", "MEMBER", "COLLABORATOR"])
|
|
|
|
|
|
def _is_actionable_review_payload(payload: dict[str, Any], event_type: str) -> bool:
|
|
"""Return whether a review event is trusted human feedback worth auto-responding to.
|
|
|
|
Approvals, the agent's own bot comments, and feedback from non-trusted
|
|
authors (read/triage/outside users) are not actionable — auto-fix-on-review
|
|
dispatches a write-capable run, so only repo collaborators/members/owners
|
|
may trigger it without an explicit ``@open-swe`` mention.
|
|
"""
|
|
action = payload.get("action", "")
|
|
if event_type == "pull_request_review_comment":
|
|
if action != "created":
|
|
return False
|
|
node = payload.get("comment") or {}
|
|
elif event_type == "pull_request_review":
|
|
if action != "submitted":
|
|
return False
|
|
node = payload.get("review") or {}
|
|
if node.get("state") not in {"changes_requested", "commented"}:
|
|
return False
|
|
else:
|
|
return False
|
|
if not isinstance(node, dict):
|
|
return False
|
|
reviewer = (node.get("user") or {}).get("login", "")
|
|
if reviewer in webapp.INTERNAL_BOT_LOGINS:
|
|
return False
|
|
if node.get("author_association") not in _TRUSTED_REVIEW_ASSOCIATIONS:
|
|
return False
|
|
body = node.get("body") or ""
|
|
return bool(body.strip())
|
|
|
|
|
|
async def process_github_autofix_review(payload: dict[str, Any], event_type: str) -> None:
|
|
"""Auto-respond to a human review/review-comment on an agent-authored PR."""
|
|
ref = _pr_ref_from_comment_payload(payload, event_type)
|
|
if ref is None:
|
|
return
|
|
comment = payload.get("comment") or payload.get("review", {})
|
|
reviewer = (comment.get("user") or {}).get("login", "") if isinstance(comment, dict) else ""
|
|
body = (comment.get("body") or "") if isinstance(comment, dict) else ""
|
|
if not body.strip() or reviewer in webapp.INTERNAL_BOT_LOGINS:
|
|
return
|
|
result = await handle_review_feedback(
|
|
repo_config={"owner": ref["owner"], "name": ref["name"]},
|
|
pr_number=ref["number"],
|
|
pr_url=ref["url"],
|
|
reviewer=reviewer,
|
|
body=body,
|
|
source="github_review",
|
|
)
|
|
webapp.logger.info(
|
|
"Auto-fix review feedback for %s/%s#%s: %s",
|
|
ref["owner"],
|
|
ref["name"],
|
|
ref["number"],
|
|
result,
|
|
)
|