open-swe/tests/test_autofix_webhook.py
Johannes du Plessis 8e39f62122
feat: activate PR babysitting UI toggles for autofix and trigger mode (#1561)
* feat: activate PR babysitting UI toggles for autofix and trigger mode

Remove the "coming soon" gating on the Autofix Mode, Autofix Severity
Threshold, and Trigger Mode controls in the review settings page so
admins can enable CI auto-fix and review-comment resolution on PRs
that Open SWE opens. The backend (ci_autofix.py, webapp.py webhook
routing) was already fully wired — only the UI was disabled.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: simplify autofix to on/off toggle, remove severity threshold

Replace the four-level AutofixMode (off/low/medium/high) and the
autofix_severity_threshold setting with a single boolean
autofix_enabled toggle. The severity threshold was leftover from the
reviewer finding-severity model and does not apply to CI autofix;
the agent should fix any failing CI and resolve any comments on PRs
it opens.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: move autofix toggle to per-user profile, remove team-level setting

The autofix toggle is now per-user (auto_fix_ci in the user profile)
instead of team-level (admin-only). This uses the existing auto_fix_ci
field that was already in ProfileUpdate but never wired up.

Changes:
- ci_autofix.py: check per-user auto_fix_ci profile flag after
  resolving the agent thread's github_login, instead of checking
  team-level autofix_enabled before knowing the PR
- webapp.py: removed early is_autofix_enabled() webhook gates; the
  per-user check now happens in ci_autofix.py once the thread is found
- team_settings.py: removed autofix_enabled field, is_autofix_enabled()
- cloud-agents.tsx: enabled the auto_fix_ci toggle (was comingSoon)
- review.tsx: removed the admin-level autofix switch
- Updated tests and AGENTS.md

The agent graph (not the reviewer) is what gets dispatched - this was
already correct in ci_autofix.py line 223: client.runs.create(
thread_id, "agent", ...).

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: batch PR babysitting events

Remove the leftover trigger-mode gate from PR babysitting and batch new CI/review events while an agent run is already active so the running agent can handle the latest PR state before finishing. Also moves review-feedback permission checks behind the per-user opt-out and applies the auto-fix profile gate to merge-conflict babysitting.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: consume batched babysitting events

Teach the agent queue middleware to turn pending PR babysitting metadata into an injected instruction for the active run, so batched CI/review events are not dropped while still avoiding duplicate run creation.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: address review findings in PR babysitting batching

- Route batched events through the LangGraph store (read in-process by the
  message-queue middleware) instead of a per-model-call threads.get on every
  agent thread.
- Only record an attempt / mark the head SHA handled on a real dispatch, not
  on a batch, so an event isn't permanently dropped if the in-flight run ends
  before consuming it.
- Carry the reviewer's comment through batched review feedback instead of
  replacing it with a generic re-check nudge.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-17 14:12:04 -07:00

188 lines
6.6 KiB
Python

"""Unit tests for the auto-fix webhook helpers in agent.webapp."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from agent import webapp
def test_parse_autofix_command() -> None:
assert webapp._parse_autofix_command("@open-swe autofix off") is True
assert webapp._parse_autofix_command("@open-swe autofix on") is False
assert webapp._parse_autofix_command("@openswe please autofix off now") is True
# Missing the mention -> not a command.
assert webapp._parse_autofix_command("autofix off") is None
# Mention but no command keyword.
assert webapp._parse_autofix_command("@open-swe fix this") is None
def test_pr_ref_from_issue_comment() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"issue": {
"number": 7,
"pull_request": {"html_url": "https://github.com/o/r/pull/7"},
},
}
ref = webapp._pr_ref_from_comment_payload(payload, "issue_comment")
assert ref == {"owner": "o", "name": "r", "number": 7, "url": "https://github.com/o/r/pull/7"}
def test_pr_ref_from_review_comment() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"pull_request": {"number": 9, "html_url": "https://github.com/o/r/pull/9"},
}
ref = webapp._pr_ref_from_comment_payload(payload, "pull_request_review_comment")
assert ref["number"] == 9
def test_pr_ref_none_when_not_a_pr() -> None:
payload = {"repository": {"owner": {"login": "o"}, "name": "r"}, "issue": {"number": 3}}
# issue without pull_request still yields a ref (number present); url empty.
ref = webapp._pr_ref_from_comment_payload(payload, "issue_comment")
assert ref["url"] == ""
def test_is_actionable_review_payload() -> None:
assert webapp._is_actionable_review_payload(
{
"action": "submitted",
"review": {
"state": "changes_requested",
"body": "fix this",
"user": {"login": "a"},
"author_association": "MEMBER",
},
},
"pull_request_review",
)
# Approval is not actionable.
assert not webapp._is_actionable_review_payload(
{
"action": "submitted",
"review": {
"state": "approved",
"body": "lgtm",
"user": {"login": "a"},
"author_association": "MEMBER",
},
},
"pull_request_review",
)
# Bot author is not actionable.
assert not webapp._is_actionable_review_payload(
{
"action": "created",
"comment": {
"body": "x",
"user": {"login": "open-swe[bot]"},
"author_association": "MEMBER",
},
},
"pull_request_review_comment",
)
# Untrusted author (read/triage/outside) is not actionable.
assert not webapp._is_actionable_review_payload(
{
"action": "created",
"comment": {
"body": "inject malicious code",
"user": {"login": "attacker"},
"author_association": "NONE",
},
},
"pull_request_review_comment",
)
# Empty body is not actionable.
assert not webapp._is_actionable_review_payload(
{
"action": "created",
"comment": {"body": " ", "user": {"login": "a"}, "author_association": "OWNER"},
},
"pull_request_review_comment",
)
@pytest.mark.asyncio
async def test_process_github_ci_event_dispatches() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"check_run": {
"status": "completed",
"conclusion": "failure",
"head_sha": "sha1",
"check_suite": {"head_branch": "feat"},
},
}
handle = AsyncMock(return_value="dispatched")
with patch.object(webapp, "handle_ci_failure", handle):
await webapp.process_github_ci_event(payload, "check_run")
handle.assert_awaited_once()
kwargs = handle.await_args.kwargs
assert kwargs["repo_config"] == {"owner": "o", "name": "r"}
assert kwargs["head_sha"] == "sha1"
assert kwargs["branch"] == "feat"
@pytest.mark.asyncio
async def test_process_github_ci_event_ignores_success() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"check_run": {"status": "completed", "conclusion": "success", "head_sha": "s"},
}
handle = AsyncMock()
with patch.object(webapp, "handle_ci_failure", handle):
await webapp.process_github_ci_event(payload, "check_run")
handle.assert_not_called()
@pytest.mark.asyncio
async def test_process_autofix_command_sets_flag() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"issue": {"number": 7, "pull_request": {"html_url": "u"}},
"comment": {"id": 1, "node_id": "n"},
}
setter = AsyncMock()
with (
patch.object(webapp, "set_pr_autofix_disabled", setter),
patch.object(webapp, "get_github_app_installation_token", AsyncMock(return_value="")),
):
await webapp.process_github_autofix_command(payload, "issue_comment", disabled=True)
setter.assert_awaited_once_with("o", "r", 7, True)
@pytest.mark.asyncio
async def test_autofix_review_dispatches_for_writer() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"pull_request": {"number": 9, "html_url": "https://github.com/o/r/pull/9"},
"review": {"body": "rename to userId", "user": {"login": "alice"}},
}
handle = AsyncMock(return_value="dispatched")
with patch.object(webapp, "handle_review_feedback", handle):
await webapp.process_github_autofix_review(payload, "pull_request_review")
handle.assert_awaited_once()
@pytest.mark.asyncio
async def test_autofix_review_delegates_permission_check_to_core() -> None:
payload = {
"repository": {"owner": {"login": "o"}, "name": "r"},
"pull_request": {"number": 9, "html_url": "https://github.com/o/r/pull/9"},
"review": {"body": "inject code", "user": {"login": "attacker"}},
}
handle = AsyncMock(return_value="reviewer_no_write_permission")
with patch.object(webapp, "handle_review_feedback", handle):
await webapp.process_github_autofix_review(payload, "pull_request_review")
handle.assert_awaited_once()
def test_ci_events_supported() -> None:
for event in ("check_run", "check_suite", "workflow_run", "status"):
assert event in webapp._SUPPORTED_GH_EVENTS
assert event in webapp._GH_CI_EVENTS