mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
* 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>
607 lines
22 KiB
Python
607 lines
22 KiB
Python
"""Auto-fix CI failures and review feedback on agent-authored pull requests.
|
|
|
|
This is the shared core for "PR babysitting": when a CI check fails (or a
|
|
reviewer leaves actionable feedback) on a PR that Open SWE opened, locate the
|
|
originating agent thread and dispatch a confidence-gated fix run on it.
|
|
|
|
Both the GitHub webhook path (:mod:`agent.webapp`) and the polling fallback
|
|
(:mod:`agent.ci_monitor`) call into here, so all the skip-rules, dedupe, and
|
|
loop-capping live in one place. Skip-rules mirror Cursor/Claude Code:
|
|
|
|
* Only PRs Open SWE authored (an agent thread with this ``pr_url`` exists).
|
|
* Skip failures inherited from the base branch.
|
|
* Skip when the latest commit was authored by a human (don't fight pushes).
|
|
* Dedupe per head SHA; cap total attempts.
|
|
* Honor the per-user ``auto_fix_ci`` profile flag and the per-PR opt-out.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from langgraph_sdk import get_client
|
|
|
|
from .dashboard.agent_overrides import load_profile, resolve_login_from_email_async
|
|
from .dashboard.autofix_state import is_pr_autofix_disabled
|
|
from .dashboard.enabled_repos import is_review_repo_enabled
|
|
from .reviewer_findings import REVIEWER_THREAD_KIND
|
|
from .utils.dashboard_links import dashboard_thread_url
|
|
from .utils.github_app import get_github_app_installation_token
|
|
from .utils.github_checks import post_autofix_status_check
|
|
from .utils.github_ci import (
|
|
fetch_open_pr_for_branch,
|
|
fetch_pr,
|
|
has_repo_write_permission,
|
|
head_commit_author_login,
|
|
list_failing_check_runs,
|
|
list_failing_statuses,
|
|
names_failing_on_base,
|
|
)
|
|
from .utils.github_org_membership import INTERNAL_BOT_LOGINS
|
|
from .utils.thread_ops import (
|
|
is_thread_active,
|
|
langgraph_client,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Hard cap on auto-fix follow-ups per PR so a failure the agent can't resolve
|
|
# doesn't loop forever (Cursor caps at 10).
|
|
MAX_AUTOFIX_ATTEMPTS = 10
|
|
# Keep the dedupe list bounded on thread metadata.
|
|
_MAX_HANDLED_KEYS = 30
|
|
# Store location for batched auto-fix events, consumed by the message-queue middleware.
|
|
_PENDING_AUTOFIX_NS = "autofix"
|
|
_PENDING_AUTOFIX_KEY = "pending_event"
|
|
|
|
|
|
def _dedupe_key(head_sha: str) -> str:
|
|
return head_sha
|
|
|
|
|
|
async def _user_autofix_enabled(github_login: str, user_email: str = "") -> bool:
|
|
"""Check the per-user ``auto_fix_ci`` profile flag (defaults to True)."""
|
|
login = github_login.strip() if isinstance(github_login, str) else ""
|
|
if not login and user_email:
|
|
login = await resolve_login_from_email_async(user_email)
|
|
if not login:
|
|
return True
|
|
profile = await load_profile(login)
|
|
if not isinstance(profile, dict):
|
|
return True
|
|
value = profile.get("auto_fix_ci")
|
|
return value if isinstance(value, bool) else True
|
|
|
|
|
|
async def find_agent_thread_for_pr(pr_url: str) -> tuple[str, dict[str, Any]] | None:
|
|
"""Return ``(thread_id, metadata)`` of the agent thread that opened ``pr_url``.
|
|
|
|
Reviewer threads are skipped — only the coding-agent thread can push fixes.
|
|
"""
|
|
if not pr_url:
|
|
return None
|
|
client = get_client()
|
|
try:
|
|
threads = await client.threads.search(metadata={"pr_url": pr_url}, limit=10)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Could not search threads for PR %s", pr_url, exc_info=True)
|
|
return None
|
|
for thread in threads or []:
|
|
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
if metadata.get("kind") == REVIEWER_THREAD_KIND:
|
|
continue
|
|
if metadata.get("agent_kind") != "agent":
|
|
continue
|
|
thread_id = thread.get("thread_id") or thread.get("id")
|
|
if isinstance(thread_id, str) and thread_id:
|
|
return thread_id, metadata
|
|
return None
|
|
|
|
|
|
def _build_ci_fix_prompt(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
pr_url: str,
|
|
branch: str,
|
|
head_sha: str,
|
|
failing_checks: list[dict[str, Any]],
|
|
) -> str:
|
|
lines = []
|
|
for check in failing_checks:
|
|
name = check.get("name", "check")
|
|
conclusion = check.get("conclusion", "failure")
|
|
details = check.get("details_url") or ""
|
|
suffix = f" — {details}" if details else ""
|
|
lines.append(f"- {name} ({conclusion}){suffix}")
|
|
failing_block = "\n".join(lines)
|
|
return (
|
|
"An automated CI check failed on a pull request you opened. Please "
|
|
"investigate and fix it.\n\n"
|
|
f"## Repository: {owner}/{repo}\n\n"
|
|
f"## Pull Request: {pr_url} (#{pr_number})\n\n"
|
|
f"## Branch: {branch}\n\n"
|
|
f"## Head commit: {head_sha}\n\n"
|
|
f"## Failing checks:\n{failing_block}\n\n"
|
|
"Instructions:\n"
|
|
"1. Make sure you are on the PR branch, then read the failing logs "
|
|
"(e.g. `GH_TOKEN=dummy gh pr checks` and `GH_TOKEN=dummy gh run view "
|
|
"<run-id> --log-failed`).\n"
|
|
"2. Confidence gating — fix autonomously ONLY when the cause is clear "
|
|
"and deterministic (lint/format, type errors, missing imports, failed "
|
|
"assertions, snapshot updates, build errors). Commit and push to the "
|
|
"existing branch; do NOT open a new PR.\n"
|
|
"3. If the failure is ambiguous, flaky, infrastructure-related, appears "
|
|
"pre-existing, or needs an architectural/design decision, do NOT guess. "
|
|
"Post a short PR comment explaining what you found and what input you "
|
|
"need, then stop.\n"
|
|
"4. Never force-push. Never weaken or delete test assertions just to go "
|
|
"green unless the behavior change is intentional and correct.\n"
|
|
"5. Before finishing, re-check the PR's latest CI status and review "
|
|
"comments. Address any newly failed checks or unhandled actionable "
|
|
"comments that arrived while you were working.\n"
|
|
"6. After you push, CI re-runs automatically — you don't need to merge."
|
|
)
|
|
|
|
|
|
def _build_review_feedback_prompt(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
pr_url: str,
|
|
reviewer: str,
|
|
body: str,
|
|
) -> str:
|
|
return (
|
|
"A reviewer left feedback on a pull request you opened. Please respond.\n\n"
|
|
f"## Repository: {owner}/{repo}\n\n"
|
|
f"## Pull Request: {pr_url} (#{pr_number})\n\n"
|
|
f"## Reviewer: {reviewer}\n\n"
|
|
f"## Feedback:\n{body}\n\n"
|
|
"Instructions:\n"
|
|
"1. If the requested change is unambiguous (rename, typo, missing null "
|
|
"check, small refactor, add a test), make it, commit, and push to the "
|
|
"existing branch.\n"
|
|
"2. If the comment is ambiguous, opinion-based, or needs a design "
|
|
"decision, reply on the PR asking for clarification instead of guessing.\n"
|
|
"3. Before finishing, re-check the PR's latest review comments and CI "
|
|
"status. Address any newly arrived actionable comments or failed checks "
|
|
"that are clear and deterministic.\n"
|
|
"4. Never force-push. Reply to the reviewer on GitHub to explain what "
|
|
"you changed."
|
|
)
|
|
|
|
|
|
async def _thread_autofix_state(metadata: dict[str, Any]) -> tuple[int, list[str], str, str]:
|
|
attempts = metadata.get("autofix_attempts")
|
|
attempts = attempts if isinstance(attempts, int) and attempts >= 0 else 0
|
|
handled = metadata.get("autofix_handled")
|
|
handled = [h for h in handled if isinstance(h, str)] if isinstance(handled, list) else []
|
|
github_login = metadata.get("github_login")
|
|
github_login = github_login if isinstance(github_login, str) else ""
|
|
user_email = metadata.get("triggering_user_email")
|
|
user_email = user_email if isinstance(user_email, str) else ""
|
|
return attempts, handled, github_login, user_email
|
|
|
|
|
|
async def _record_attempt(
|
|
thread_id: str, *, attempts: int, handled: list[str], dedupe_key: str, head_sha: str
|
|
) -> None:
|
|
new_handled = [*handled, dedupe_key][-_MAX_HANDLED_KEYS:]
|
|
try:
|
|
await get_client().threads.update(
|
|
thread_id=thread_id,
|
|
metadata={
|
|
"autofix_attempts": attempts + 1,
|
|
"autofix_handled": new_handled,
|
|
"autofix_last_head_sha": head_sha,
|
|
},
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Failed to record auto-fix attempt for thread %s", thread_id, exc_info=True)
|
|
|
|
|
|
# Run sources the agent's GitHub-token resolver knows how to authenticate.
|
|
_AUTH_RESOLVABLE_SOURCES = frozenset(["github", "slack", "dashboard", "linear", "schedule"])
|
|
|
|
|
|
def _run_configurable(
|
|
metadata: dict[str, Any], *, repo_config: dict[str, str], pr_number: int
|
|
) -> dict[str, Any]:
|
|
"""Build the run config for a fix run by reusing the PR thread's identity.
|
|
|
|
The agent's GitHub-token resolver only authenticates known sources, so a
|
|
bespoke ``github_ci`` source would fail in non-bot-token deployments. Reuse
|
|
the originating thread's ``source`` + login/email so auth resolves exactly
|
|
as it did for the run that opened the PR.
|
|
"""
|
|
source = metadata.get("source")
|
|
if source not in _AUTH_RESOLVABLE_SOURCES:
|
|
source = "github"
|
|
configurable: dict[str, Any] = {
|
|
"source": source,
|
|
"repo": repo_config,
|
|
"pr_number": pr_number,
|
|
}
|
|
login = metadata.get("github_login")
|
|
if isinstance(login, str) and login:
|
|
configurable["github_login"] = login
|
|
email = metadata.get("triggering_user_email")
|
|
if isinstance(email, str) and email:
|
|
configurable["user_email"] = email
|
|
return configurable
|
|
|
|
|
|
async def _mark_pending_autofix_event(thread_id: str, reason: str, detail: str = "") -> None:
|
|
"""Record a batched auto-fix event in the store the message-queue middleware reads.
|
|
|
|
Uses the same store namespace mechanism as ``queue_message_for_thread`` so the
|
|
in-flight run picks it up in-process at its next ``before_model`` step — no
|
|
per-step thread fetch. ``detail`` (e.g. a reviewer's comment) is accumulated so
|
|
specifics aren't lost when several events batch against one busy run.
|
|
"""
|
|
client = langgraph_client()
|
|
namespace = (_PENDING_AUTOFIX_NS, thread_id)
|
|
try:
|
|
details: list[str] = []
|
|
try:
|
|
existing = await client.store.get_item(namespace, _PENDING_AUTOFIX_KEY)
|
|
if existing and existing.get("value"):
|
|
prior = existing["value"].get("details")
|
|
if isinstance(prior, list):
|
|
details = [d for d in prior if isinstance(d, str)]
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("No existing pending auto-fix event for thread %s", thread_id)
|
|
if detail and detail not in details:
|
|
details.append(detail)
|
|
await client.store.put_item(
|
|
namespace, _PENDING_AUTOFIX_KEY, {"reason": reason, "details": details}
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug(
|
|
"Failed to record pending auto-fix event for thread %s", thread_id, exc_info=True
|
|
)
|
|
|
|
|
|
async def _dispatch_or_batch(
|
|
thread_id: str, prompt: str, *, configurable: dict[str, Any], reason: str, detail: str = ""
|
|
) -> str:
|
|
if await is_thread_active(thread_id):
|
|
logger.info("Agent thread %s busy; batching auto-fix event %s", thread_id, reason)
|
|
await _mark_pending_autofix_event(thread_id, reason, detail)
|
|
return "batched"
|
|
client = langgraph_client()
|
|
await client.runs.create(
|
|
thread_id,
|
|
"agent",
|
|
input={"messages": [{"role": "user", "content": prompt}]},
|
|
config={"configurable": configurable},
|
|
if_not_exists="create",
|
|
)
|
|
logger.info(
|
|
"Created auto-fix run for thread %s (source=%s)", thread_id, configurable.get("source")
|
|
)
|
|
return "dispatched"
|
|
|
|
|
|
async def handle_ci_failure(
|
|
*,
|
|
repo_config: dict[str, str],
|
|
branch: str,
|
|
head_sha: str,
|
|
token: str | None = None,
|
|
source: str = "github_ci",
|
|
failing_checks: list[dict[str, Any]] | None = None,
|
|
pr: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""Auto-fix failing CI on an agent-authored PR. Returns a status string."""
|
|
owner = repo_config.get("owner", "")
|
|
repo = repo_config.get("name", "")
|
|
if not owner or not repo:
|
|
return "missing_repo"
|
|
|
|
if not await is_review_repo_enabled(owner, repo):
|
|
return "repo_not_enabled"
|
|
|
|
if token is None:
|
|
token = await get_github_app_installation_token()
|
|
if not token:
|
|
logger.warning("No GitHub App token for CI auto-fix on %s/%s", owner, repo)
|
|
return "no_token"
|
|
|
|
if pr is None:
|
|
if not branch:
|
|
return "no_branch"
|
|
pr = await fetch_open_pr_for_branch(owner=owner, repo=repo, branch=branch, token=token)
|
|
if not pr:
|
|
return "no_open_pr"
|
|
|
|
pr_number = pr.get("number")
|
|
if not isinstance(pr_number, int):
|
|
return "no_pr_number"
|
|
pr_url = pr.get("html_url") or pr.get("url") or ""
|
|
base_sha = (pr.get("base") or {}).get("sha", "")
|
|
branch = branch or (pr.get("head") or {}).get("ref", "")
|
|
head_sha = head_sha or (pr.get("head") or {}).get("sha", "")
|
|
if not head_sha:
|
|
return "no_head_sha"
|
|
|
|
if await is_pr_autofix_disabled(owner, repo, pr_number):
|
|
return "pr_disabled"
|
|
|
|
found = await find_agent_thread_for_pr(pr_url)
|
|
if found is None:
|
|
return "no_agent_thread"
|
|
thread_id, metadata = found
|
|
|
|
attempts, handled, github_login, user_email = await _thread_autofix_state(metadata)
|
|
|
|
if not await _user_autofix_enabled(github_login, user_email):
|
|
return "autofix_disabled_user"
|
|
|
|
if attempts >= MAX_AUTOFIX_ATTEMPTS:
|
|
await post_autofix_status_check(
|
|
owner=owner,
|
|
repo=repo,
|
|
head_sha=head_sha,
|
|
token=token,
|
|
title="Auto-fix limit reached",
|
|
summary=(
|
|
f"Open SWE has attempted {attempts} auto-fixes on this PR and "
|
|
"stopped to avoid a loop. Push a commit or comment to continue."
|
|
),
|
|
details_url=dashboard_thread_url(thread_id),
|
|
)
|
|
return "max_attempts"
|
|
|
|
if failing_checks is None:
|
|
runs = await list_failing_check_runs(owner=owner, repo=repo, ref=head_sha, token=token)
|
|
statuses = await list_failing_statuses(owner=owner, repo=repo, ref=head_sha, token=token)
|
|
if runs is None and statuses is None:
|
|
return "ci_read_failed"
|
|
failing_checks = (runs or []) + (statuses or [])
|
|
if not failing_checks:
|
|
return "no_failing_checks"
|
|
|
|
base_failing = await names_failing_on_base(
|
|
owner=owner, repo=repo, base_sha=base_sha, token=token
|
|
)
|
|
actionable = [c for c in failing_checks if c.get("name") not in base_failing]
|
|
if not actionable:
|
|
return "all_failing_on_base"
|
|
|
|
dedupe_key = _dedupe_key(head_sha)
|
|
if dedupe_key in handled:
|
|
return "already_handled"
|
|
|
|
author_login = await head_commit_author_login(owner=owner, repo=repo, sha=head_sha, token=token)
|
|
if (
|
|
author_login is not None
|
|
and author_login not in INTERNAL_BOT_LOGINS
|
|
and (not github_login or author_login.lower() != github_login.lower())
|
|
):
|
|
logger.info(
|
|
"Skipping CI auto-fix on %s/%s#%s: head commit %s authored by human %s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
head_sha,
|
|
author_login,
|
|
)
|
|
return "human_commit"
|
|
|
|
prompt = _build_ci_fix_prompt(
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url,
|
|
branch=branch,
|
|
head_sha=head_sha,
|
|
failing_checks=actionable,
|
|
)
|
|
result = await _dispatch_or_batch(
|
|
thread_id,
|
|
prompt,
|
|
configurable=_run_configurable(
|
|
metadata, repo_config={"owner": owner, "name": repo}, pr_number=pr_number
|
|
),
|
|
reason="ci_failure",
|
|
)
|
|
if result == "dispatched":
|
|
# Only burn an attempt / mark the SHA handled on a real dispatch. A batched
|
|
# event is just a nudge to the in-flight run; if that run ends before
|
|
# consuming it, leaving the SHA un-handled lets a later webhook or the sweep
|
|
# re-dispatch instead of silently dropping the failure.
|
|
await _record_attempt(
|
|
thread_id, attempts=attempts, handled=handled, dedupe_key=dedupe_key, head_sha=head_sha
|
|
)
|
|
await post_autofix_status_check(
|
|
owner=owner,
|
|
repo=repo,
|
|
head_sha=head_sha,
|
|
token=token,
|
|
title=f"Auto-fixing {len(actionable)} failing check(s)",
|
|
summary=(
|
|
"Open SWE is investigating the failing checks and will push a fix if "
|
|
"the cause is clear. Track progress in the linked run."
|
|
),
|
|
details_url=dashboard_thread_url(thread_id),
|
|
)
|
|
return result
|
|
|
|
|
|
async def handle_review_feedback(
|
|
*,
|
|
repo_config: dict[str, str],
|
|
pr_number: int,
|
|
pr_url: str,
|
|
reviewer: str,
|
|
body: str,
|
|
token: str | None = None,
|
|
source: str = "github_review",
|
|
) -> str:
|
|
"""Auto-respond to a human review comment on an agent-authored PR."""
|
|
owner = repo_config.get("owner", "")
|
|
repo = repo_config.get("name", "")
|
|
if not owner or not repo or not pr_url:
|
|
return "missing_repo"
|
|
|
|
if not await is_review_repo_enabled(owner, repo):
|
|
return "repo_not_enabled"
|
|
if await is_pr_autofix_disabled(owner, repo, pr_number):
|
|
return "pr_disabled"
|
|
|
|
found = await find_agent_thread_for_pr(pr_url)
|
|
if found is None:
|
|
return "no_agent_thread"
|
|
thread_id, metadata = found
|
|
|
|
_, _, github_login, user_email = await _thread_autofix_state(metadata)
|
|
if not await _user_autofix_enabled(github_login, user_email):
|
|
return "autofix_disabled_user"
|
|
|
|
if token is None:
|
|
token = await get_github_app_installation_token()
|
|
if not token:
|
|
return "no_token"
|
|
if not await has_repo_write_permission(owner=owner, repo=repo, username=reviewer, token=token):
|
|
logger.info(
|
|
"Skipping auto-fix review feedback on %s/%s#%s: %s lacks write access",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
reviewer or "<unknown>",
|
|
)
|
|
return "reviewer_no_write_permission"
|
|
|
|
prompt = _build_review_feedback_prompt(
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url,
|
|
reviewer=reviewer,
|
|
body=body,
|
|
)
|
|
return await _dispatch_or_batch(
|
|
thread_id,
|
|
prompt,
|
|
configurable=_run_configurable(
|
|
metadata, repo_config={"owner": owner, "name": repo}, pr_number=pr_number
|
|
),
|
|
reason="review_feedback",
|
|
detail=f"Reviewer {reviewer or 'unknown'} commented: {body.strip()}"
|
|
if body.strip()
|
|
else "",
|
|
)
|
|
|
|
|
|
async def sweep_open_prs() -> dict[str, int]:
|
|
"""Poll open agent-authored PRs and auto-fix failing CI / flag conflicts.
|
|
|
|
The polling fallback for deployments without reliable CI webhooks, and the
|
|
only path that can react to base-branch merge conflicts (GitHub emits no
|
|
webhook for those).
|
|
"""
|
|
counts = {"scanned": 0, "dispatched": 0, "batched": 0, "conflicts": 0}
|
|
token = await get_github_app_installation_token()
|
|
if not token:
|
|
logger.warning("CI monitor sweep: no GitHub App token")
|
|
return counts
|
|
client = get_client()
|
|
try:
|
|
threads = await client.threads.search(
|
|
metadata={"agent_kind": "agent", "pr_state": "open"}, limit=100
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("CI monitor sweep: thread search failed", exc_info=True)
|
|
return counts
|
|
|
|
for thread in threads or []:
|
|
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
repo = metadata.get("repo")
|
|
pr_number = metadata.get("pr_number")
|
|
branch = metadata.get("branch_name")
|
|
if not isinstance(repo, dict) or not isinstance(pr_number, int):
|
|
continue
|
|
owner = repo.get("owner", "")
|
|
name = repo.get("name", "")
|
|
if not owner or not name:
|
|
continue
|
|
counts["scanned"] += 1
|
|
pr = await fetch_pr(owner=owner, repo=name, pr_number=pr_number, token=token)
|
|
if not pr:
|
|
continue
|
|
head_sha = (pr.get("head") or {}).get("sha", "")
|
|
branch = (pr.get("head") or {}).get("ref", "") or (
|
|
branch if isinstance(branch, str) else ""
|
|
)
|
|
if pr.get("mergeable_state") == "dirty":
|
|
counts["conflicts"] += 1
|
|
await _flag_merge_conflict(
|
|
owner=owner,
|
|
repo=name,
|
|
pr_number=pr_number,
|
|
pr_url=pr.get("html_url") or "",
|
|
head_sha=head_sha,
|
|
token=token,
|
|
)
|
|
continue
|
|
result = await handle_ci_failure(
|
|
repo_config={"owner": owner, "name": name},
|
|
branch=branch,
|
|
head_sha=head_sha,
|
|
token=token,
|
|
source="ci_monitor",
|
|
pr=pr,
|
|
)
|
|
if result == "dispatched":
|
|
counts["dispatched"] += 1
|
|
elif result == "batched":
|
|
counts["batched"] += 1
|
|
logger.info("CI monitor sweep complete: %s", counts)
|
|
return counts
|
|
|
|
|
|
async def _flag_merge_conflict(
|
|
*, owner: str, repo: str, pr_number: int, pr_url: str, head_sha: str, token: str
|
|
) -> None:
|
|
"""Ask the agent to rebase a PR that has merge conflicts with its base."""
|
|
if await is_pr_autofix_disabled(owner, repo, pr_number):
|
|
return
|
|
found = await find_agent_thread_for_pr(pr_url)
|
|
if found is None:
|
|
return
|
|
thread_id, metadata = found
|
|
_, _, github_login, user_email = await _thread_autofix_state(metadata)
|
|
if not await _user_autofix_enabled(github_login, user_email):
|
|
return
|
|
if metadata.get("autofix_conflict_head") == head_sha:
|
|
return
|
|
prompt = (
|
|
f"The pull request you opened (#{pr_number}, {pr_url}) now has merge "
|
|
"conflicts with its base branch. Rebase or merge the base branch into "
|
|
"the PR branch, resolve the conflicts carefully, and push. If a "
|
|
"conflict resolution is ambiguous, comment on the PR and ask before "
|
|
"guessing. Never force-push over commits already on the remote."
|
|
)
|
|
await _dispatch_or_batch(
|
|
thread_id,
|
|
prompt,
|
|
configurable=_run_configurable(
|
|
metadata, repo_config={"owner": owner, "name": repo}, pr_number=pr_number
|
|
),
|
|
reason="merge_conflict",
|
|
)
|
|
try:
|
|
await get_client().threads.update(
|
|
thread_id=thread_id, metadata={"autofix_conflict_head": head_sha}
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Failed to record conflict head for thread %s", thread_id, exc_info=True)
|