mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
refactor: split webapp.py into api/ + per-source webhook routes
Plan step C4 (docs/upstream-sync/domain-reorg/reorg-build-plan.md, approved
decisions 1-2): split the 2,590-line agent/webapp.py monolith into
agent/webhooks/common.py (shared verify/dispatch helpers), agent/api/app.py
(composition), agent/api/health.py (/health + /webhooks/run-complete), and
per-source {github,linear,slack,jira,confluence}_routes.py. Atlassian
Connect lifecycle + descriptor routes (/connect/*) fold into
confluence_routes.py; webapp.py becomes the upstream-shaped compatibility
shim (from .api.app import app). langgraph.json http.app stays
agent.webapp:app via the shim.
Fork content, upstream layout: linear/slack route files verified
content-identical to upstream 8356eb34 and taken verbatim; github_routes is
upstream + the fork's CI auto-fix trigger wiring; jira/confluence routes are
fork-only, transformed to the same common.X / service.X module-attribute
style. All signature verification (GitHub HMAC, Slack, Linear
timestamp-freshness, verify_jira_secret + opt-in HMAC/timestamp/IP
allowlist, Connect JWT/qsh), token-attribution gating, TID-COLLIDE-01 repo
binding, _is_repo_auto_review_enabled gates, and public-repo org gate move
unchanged.
Handlers rewired from webapp.X to common.X; test monkeypatch sites across
26 files + conftest.py + e2e/harness.py retargeted to
webhook_common/handler/route modules per upstream's pattern. Residual
agent.webapp importers: only the shim, langgraph.json http.app, Makefile
uvicorn target, and docs (doc-path updates land in C7).
Gates: ruff check + format, pytest --co, full unit (1637 passed), full
Playwright E2E vs real langgraph dev (9/9), residual-importer sweep.
This commit is contained in:
parent
532788d409
commit
b3fc62da80
46 changed files with 3894 additions and 3534 deletions
1
agent/api/__init__.py
Normal file
1
agent/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""FastAPI application composition."""
|
||||||
57
agent/api/app.py
Normal file
57
agent/api/app.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""FastAPI application composition."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from ..dashboard import router as dashboard_router
|
||||||
|
from ..dashboard.plan_api import plan_router
|
||||||
|
from ..dashboard.workflow_approval_api import workflow_approval_router
|
||||||
|
from ..webhooks.confluence_routes import router as confluence_webhook_router
|
||||||
|
from ..webhooks.github_routes import router as github_webhook_router
|
||||||
|
from ..webhooks.jira_routes import router as jira_webhook_router
|
||||||
|
from ..webhooks.linear_routes import router as linear_webhook_router
|
||||||
|
from ..webhooks.slack_routes import router as slack_webhook_router
|
||||||
|
from .health import router as health_router
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
from ..utils.model import validate_local_dev_llm_config
|
||||||
|
from ..utils.sandbox import validate_sandbox_startup_config
|
||||||
|
|
||||||
|
validate_sandbox_startup_config()
|
||||||
|
validate_local_dev_llm_config()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
DASHBOARD_ALLOWED_ORIGINS: list[str] = [
|
||||||
|
o.strip() for o in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(",") if o.strip()
|
||||||
|
]
|
||||||
|
if DASHBOARD_ALLOWED_ORIGINS:
|
||||||
|
if "*" in DASHBOARD_ALLOWED_ORIGINS:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DASHBOARD_ALLOWED_ORIGINS must not include '*' when allow_credentials=True"
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=DASHBOARD_ALLOWED_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(plan_router)
|
||||||
|
app.include_router(workflow_approval_router)
|
||||||
|
app.include_router(linear_webhook_router)
|
||||||
|
app.include_router(jira_webhook_router)
|
||||||
|
app.include_router(confluence_webhook_router)
|
||||||
|
app.include_router(slack_webhook_router)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(github_webhook_router)
|
||||||
27
agent/api/health.py
Normal file
27
agent/api/health.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Health and run-completion routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from ..completion import handle_run_completion, verify_run_complete_token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check() -> dict[str, str]:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/run-complete")
|
||||||
|
async def run_complete_webhook(request: Request) -> dict[str, str]:
|
||||||
|
"""Platform run-completion webhook: post a failure reply for runs that died."""
|
||||||
|
if not verify_run_complete_token(request.query_params.get("token")):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid run-complete token")
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {"status": "ignored", "reason": "payload not an object"}
|
||||||
|
return await handle_run_completion(payload)
|
||||||
|
|
@ -4,7 +4,7 @@ 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
|
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.
|
originating agent thread and dispatch a confidence-gated fix run on it.
|
||||||
|
|
||||||
Both the GitHub webhook path (:mod:`agent.webapp`) and the polling fallback
|
Both the GitHub webhook path (:mod:`agent.webhooks.github_routes`) and the polling fallback
|
||||||
(:mod:`agent.ci_monitor`) call into here, so all the skip-rules, dedupe, and
|
(: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:
|
loop-capping live in one place. Skip-rules mirror Cursor/Claude Code:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
``router`` is loaded lazily (PEP 562): importing any dashboard submodule
|
``router`` is loaded lazily (PEP 562): importing any dashboard submodule
|
||||||
(e.g. ``agent.dashboard.options`` from middleware) executes this __init__,
|
(e.g. ``agent.dashboard.options`` from middleware) executes this __init__,
|
||||||
and it must NOT drag in routes.py + FastAPI + every API/job module. Only the
|
and it must NOT drag in routes.py + FastAPI + every API/job module. Only the
|
||||||
webapp, which actually mounts the router, pays that cost.
|
API app (``agent.api.app``), which actually mounts the router, pays that cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
|
||||||
|
|
@ -697,7 +697,7 @@ async def proxy_pr_image(owner: str, repo: str, pr_number: int, url: str) -> Res
|
||||||
|
|
||||||
async def trigger_re_review(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
|
async def trigger_re_review(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
|
||||||
from ..utils.slack import GitHubPrRef
|
from ..utils.slack import GitHubPrRef
|
||||||
from ..webapp import trigger_pr_review_from_ref
|
from ..webhooks.github import trigger_pr_review_from_ref
|
||||||
|
|
||||||
pr_ref = GitHubPrRef(
|
pr_ref = GitHubPrRef(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|
@ -718,7 +718,7 @@ async def dry_run_trace_resolution(owner: str, repo: str, pr_number: int) -> dic
|
||||||
from ..review.trace_context import resolve_pr_trace
|
from ..review.trace_context import resolve_pr_trace
|
||||||
from ..utils.github_app import get_github_app_installation_token_with_expiry
|
from ..utils.github_app import get_github_app_installation_token_with_expiry
|
||||||
from ..utils.slack import GitHubPrRef
|
from ..utils.slack import GitHubPrRef
|
||||||
from ..webapp import fetch_github_pr_metadata
|
from ..webhooks.common import fetch_github_pr_metadata
|
||||||
|
|
||||||
pr_ref = GitHubPrRef(
|
pr_ref = GitHubPrRef(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ _ASSISTANT_ID = "analyzer"
|
||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
"""LangGraph SDK client for the current deployment (same resolution as webapp)."""
|
"""LangGraph SDK client for the current deployment (same resolution as webhook common)."""
|
||||||
url = os.environ.get("LANGGRAPH_URL") or os.environ.get("LANGGRAPH_URL_PROD")
|
url = os.environ.get("LANGGRAPH_URL") or os.environ.get("LANGGRAPH_URL_PROD")
|
||||||
if url:
|
if url:
|
||||||
return get_client(url=url)
|
return get_client(url=url)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ async def trigger_pr_review_from_ref(
|
||||||
slack_channel_id: str = "",
|
slack_channel_id: str = "",
|
||||||
slack_thread_ts: str = "",
|
slack_thread_ts: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from agent.webapp import trigger_pr_review_from_ref as _trigger_pr_review_from_ref
|
from agent.webhooks.github import trigger_pr_review_from_ref as _trigger_pr_review_from_ref
|
||||||
|
|
||||||
return await _trigger_pr_review_from_ref(
|
return await _trigger_pr_review_from_ref(
|
||||||
pr_ref,
|
pr_ref,
|
||||||
|
|
|
||||||
2591
agent/webapp.py
2591
agent/webapp.py
File diff suppressed because it is too large
Load diff
1732
agent/webhooks/common.py
Normal file
1732
agent/webhooks/common.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,9 +12,10 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.messages.content import create_text_block
|
from langchain_core.messages.content import create_text_block
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.utils import atlassian_connect as ac
|
from agent.utils import atlassian_connect as ac
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
|
||||||
# The Connect app's own Confluence service-account accountId. When set, comments
|
# The Connect app's own Confluence service-account accountId. When set, comments
|
||||||
# authored by it are ignored (self-trigger loop guard, like the Linear botActor
|
# authored by it are ignored (self-trigger loop guard, like the Linear botActor
|
||||||
# / Jira comment_author_is_bot early-outs).
|
# / Jira comment_author_is_bot early-outs).
|
||||||
|
|
@ -39,7 +40,7 @@ async def process_install(request: Any, body: dict[str, Any]) -> tuple[int, str]
|
||||||
|
|
||||||
claims = await ac.verify_asymmetric_install_jwt(request, expected_client_key=client_key)
|
claims = await ac.verify_asymmetric_install_jwt(request, expected_client_key=client_key)
|
||||||
if claims is None:
|
if claims is None:
|
||||||
webapp.logger.warning("Rejecting Connect install for %s: signature unverified", client_key)
|
common.logger.warning("Rejecting Connect install for %s: signature unverified", client_key)
|
||||||
return 401, "Install verification failed"
|
return 401, "Install verification failed"
|
||||||
|
|
||||||
# Mandatory tenant binding: signed-install proves the caller is *an*
|
# Mandatory tenant binding: signed-install proves the caller is *an*
|
||||||
|
|
@ -47,7 +48,7 @@ async def process_install(request: Any, body: dict[str, Any]) -> tuple[int, str]
|
||||||
# (signature-verified) clientKey are accepted. To bootstrap, add the
|
# (signature-verified) clientKey are accepted. To bootstrap, add the
|
||||||
# clientKey logged here to CONNECT_EXPECTED_CLIENT_KEYS and re-install.
|
# clientKey logged here to CONNECT_EXPECTED_CLIENT_KEYS and re-install.
|
||||||
if not ac.client_key_allowed(client_key):
|
if not ac.client_key_allowed(client_key):
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Rejecting Connect install: clientKey %s not in CONNECT_EXPECTED_CLIENT_KEYS",
|
"Rejecting Connect install: clientKey %s not in CONNECT_EXPECTED_CLIENT_KEYS",
|
||||||
client_key,
|
client_key,
|
||||||
)
|
)
|
||||||
|
|
@ -56,14 +57,14 @@ async def process_install(request: Any, body: dict[str, Any]) -> tuple[int, str]
|
||||||
# Defense-in-depth (only enforced when configured): the callback's baseUrl
|
# Defense-in-depth (only enforced when configured): the callback's baseUrl
|
||||||
# host must be our Confluence site.
|
# host must be our Confluence site.
|
||||||
if ac.CONNECT_EXPECTED_BASE_URL_HOSTS and not ac.base_url_host_allowed(base_url):
|
if ac.CONNECT_EXPECTED_BASE_URL_HOSTS and not ac.base_url_host_allowed(base_url):
|
||||||
webapp.logger.warning("Rejecting Connect install: baseUrl %s not allowed", base_url)
|
common.logger.warning("Rejecting Connect install: baseUrl %s not allowed", base_url)
|
||||||
return 403, "baseUrl host not allowed"
|
return 403, "baseUrl host not allowed"
|
||||||
|
|
||||||
existing = await ac.get_installation(client_key)
|
existing = await ac.get_installation(client_key)
|
||||||
await ac.put_installation(
|
await ac.put_installation(
|
||||||
client_key, shared_secret, base_url, product_type, first_install=existing is None
|
client_key, shared_secret, base_url, product_type, first_install=existing is None
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Connect %s verified and stored for %s",
|
"Connect %s verified and stored for %s",
|
||||||
"first-install" if existing is None else "re-install",
|
"first-install" if existing is None else "re-install",
|
||||||
client_key,
|
client_key,
|
||||||
|
|
@ -78,13 +79,13 @@ async def process_uninstall(request: Any, body: dict[str, Any]) -> tuple[int, st
|
||||||
return 400, "Missing clientKey"
|
return 400, "Missing clientKey"
|
||||||
claims = await ac.verify_asymmetric_install_jwt(request, expected_client_key=client_key)
|
claims = await ac.verify_asymmetric_install_jwt(request, expected_client_key=client_key)
|
||||||
if claims is None:
|
if claims is None:
|
||||||
webapp.logger.warning("Rejecting Connect uninstall for %s: unverified", client_key)
|
common.logger.warning("Rejecting Connect uninstall for %s: unverified", client_key)
|
||||||
return 401, "Uninstall verification failed"
|
return 401, "Uninstall verification failed"
|
||||||
existing = await ac.get_installation(client_key)
|
existing = await ac.get_installation(client_key)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return 204, "" # idempotent
|
return 204, "" # idempotent
|
||||||
await ac.delete_installation(client_key)
|
await ac.delete_installation(client_key)
|
||||||
webapp.logger.info("Connect uninstall verified for %s", client_key)
|
common.logger.info("Connect uninstall verified for %s", client_key)
|
||||||
return 204, ""
|
return 204, ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,12 +105,12 @@ async def process_confluence_comment(payload: dict[str, Any], client_key: str =
|
||||||
"""Corroborate a comment_created event server-side and dispatch a run."""
|
"""Corroborate a comment_created event server-side and dispatch a run."""
|
||||||
comment_id = _extract_comment_id(payload)
|
comment_id = _extract_comment_id(payload)
|
||||||
if not comment_id:
|
if not comment_id:
|
||||||
webapp.logger.debug("Ignoring Confluence webhook: no comment id in payload")
|
common.logger.debug("Ignoring Confluence webhook: no comment id in payload")
|
||||||
return
|
return
|
||||||
|
|
||||||
server_comment = await webapp.fetch_confluence_comment(comment_id)
|
server_comment = await common.fetch_confluence_comment(comment_id)
|
||||||
if not server_comment:
|
if not server_comment:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Rejecting Confluence webhook: comment %s could not be corroborated", comment_id
|
"Rejecting Confluence webhook: comment %s could not be corroborated", comment_id
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -124,43 +125,43 @@ async def process_confluence_comment(payload: dict[str, Any], client_key: str =
|
||||||
# Self-trigger loop guard: ignore the app's own comments (its confluence_comment
|
# Self-trigger loop guard: ignore the app's own comments (its confluence_comment
|
||||||
# replies can echo "@openswe" and otherwise re-trigger).
|
# replies can echo "@openswe" and otherwise re-trigger).
|
||||||
if CONFLUENCE_BOT_ACCOUNT_ID and account_id == CONFLUENCE_BOT_ACCOUNT_ID:
|
if CONFLUENCE_BOT_ACCOUNT_ID and account_id == CONFLUENCE_BOT_ACCOUNT_ID:
|
||||||
webapp.logger.debug("Ignoring Confluence webhook: comment authored by the bot account")
|
common.logger.debug("Ignoring Confluence webhook: comment authored by the bot account")
|
||||||
return
|
return
|
||||||
for prefix in webapp._GITHUB_BOT_MESSAGE_PREFIXES:
|
for prefix in common._GITHUB_BOT_MESSAGE_PREFIXES:
|
||||||
if body_text.startswith(prefix):
|
if body_text.startswith(prefix):
|
||||||
webapp.logger.debug("Ignoring Confluence webhook: comment is our own bot message")
|
common.logger.debug("Ignoring Confluence webhook: comment is our own bot message")
|
||||||
return
|
return
|
||||||
if "@openswe" not in body_text.lower():
|
if "@openswe" not in body_text.lower():
|
||||||
webapp.logger.debug("Ignoring Confluence webhook: comment doesn't mention @openswe")
|
common.logger.debug("Ignoring Confluence webhook: comment doesn't mention @openswe")
|
||||||
return
|
return
|
||||||
|
|
||||||
actor_email = await webapp.get_confluence_user_email(account_id) if account_id else None
|
actor_email = await common.get_confluence_user_email(account_id) if account_id else None
|
||||||
|
|
||||||
repo_config = webapp.extract_repo_from_text(body_text, default_owner=webapp.DEFAULT_REPO_OWNER)
|
repo_config = common.extract_repo_from_text(body_text, default_owner=common.DEFAULT_REPO_OWNER)
|
||||||
if not repo_config:
|
if not repo_config:
|
||||||
repo_config = webapp.get_repo_config_from_confluence_mapping(space_key)
|
repo_config = common.get_repo_config_from_confluence_mapping(space_key)
|
||||||
if not repo_config:
|
if not repo_config:
|
||||||
repo_config = await webapp.get_team_default_repo()
|
repo_config = await common.get_team_default_repo()
|
||||||
if not repo_config:
|
if not repo_config:
|
||||||
webapp.logger.info("Ignoring Confluence webhook: no repo resolved for space %s", space_key)
|
common.logger.info("Ignoring Confluence webhook: no repo resolved for space %s", space_key)
|
||||||
return
|
return
|
||||||
if not webapp._is_repo_allowed(repo_config):
|
if not common._is_repo_allowed(repo_config):
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Rejecting Confluence webhook: repo '%s/%s' not in allowlist",
|
"Rejecting Confluence webhook: repo '%s/%s' not in allowlist",
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
repo_config.get("name"),
|
repo_config.get("name"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None
|
mapped_login = await common.resolve_login_from_email_async(actor_email) if actor_email else None
|
||||||
if mapped_login and not webapp.is_login_mapped(mapped_login):
|
if mapped_login and not common.is_login_mapped(mapped_login):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Confluence actor login %s is not an active mapping; running unattributed", mapped_login
|
"Confluence actor login %s is not an active mapping; running unattributed", mapped_login
|
||||||
)
|
)
|
||||||
mapped_login = None
|
mapped_login = None
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_confluence_comment(client_key, comment_id)
|
thread_id = common.generate_thread_id_from_confluence_comment(client_key, comment_id)
|
||||||
page = await webapp.fetch_confluence_page(page_id) if page_id else None
|
page = await common.fetch_confluence_page(page_id) if page_id else None
|
||||||
page_title = (page or {}).get("title", "") or "Confluence page"
|
page_title = (page or {}).get("title", "") or "Confluence page"
|
||||||
page_url = (page or {}).get("url", "")
|
page_url = (page or {}).get("url", "")
|
||||||
|
|
||||||
|
|
@ -191,7 +192,7 @@ async def process_confluence_comment(payload: dict[str, Any], client_key: str =
|
||||||
if mapped_login:
|
if mapped_login:
|
||||||
configurable["github_login"] = mapped_login
|
configurable["github_login"] = mapped_login
|
||||||
|
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="confluence",
|
source="confluence",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -201,14 +202,14 @@ async def process_confluence_comment(payload: dict[str, Any], client_key: str =
|
||||||
source_context={"confluence": configurable["confluence"]},
|
source_context={"confluence": configurable["confluence"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
content_blocks,
|
content_blocks,
|
||||||
configurable,
|
configurable,
|
||||||
source="confluence",
|
source="confluence",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"LangGraph run dispatched for Confluence thread %s (run=%s)",
|
"LangGraph run dispatched for Confluence thread %s (run=%s)",
|
||||||
thread_id,
|
thread_id,
|
||||||
run.get("run_id") if isinstance(run, dict) else None,
|
run.get("run_id") if isinstance(run, dict) else None,
|
||||||
|
|
|
||||||
81
agent/webhooks/confluence_routes.py
Normal file
81
agent/webhooks/confluence_routes.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Confluence webhook HTTP routes (Atlassian Connect app).
|
||||||
|
|
||||||
|
The Confluence trigger is a private Atlassian Connect app, so the descriptor
|
||||||
|
and install/uninstall lifecycle callbacks (``/connect/*``) live here alongside
|
||||||
|
the JWT-verified ``comment_created`` webhook. JWT/qsh verification machinery
|
||||||
|
stays in ``agent.utils.atlassian_connect``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import confluence as service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect/atlassian-connect.json")
|
||||||
|
async def connect_descriptor() -> dict[str, common.Any]:
|
||||||
|
"""Serve the Atlassian Connect app descriptor (baseUrl from CONNECT_BASE_URL).
|
||||||
|
|
||||||
|
signed-install is true: Atlassian asymmetrically (RS256) signs the lifecycle
|
||||||
|
callbacks, so install/uninstall are cryptographically authenticated against
|
||||||
|
Atlassian's published keys (no trust-on-first-use). The comment_created
|
||||||
|
webhook stays symmetric (HS256 against the stored per-tenant sharedSecret).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"key": "sea-haven-open-swe-confluence",
|
||||||
|
"name": "Open SWE",
|
||||||
|
"description": "Triggers Open SWE runs from Confluence comments mentioning @openswe.",
|
||||||
|
"baseUrl": common.CONNECT_BASE_URL,
|
||||||
|
"vendor": {"name": "Sea Haven Industries", "url": "https://seahavenind.com"},
|
||||||
|
"authentication": {"type": "jwt"},
|
||||||
|
"apiMigrations": {"signed-install": True, "gdpr": True},
|
||||||
|
"lifecycle": {"installed": "/connect/installed", "uninstalled": "/connect/uninstalled"},
|
||||||
|
"scopes": ["READ"],
|
||||||
|
"modules": {
|
||||||
|
"webhooks": [{"event": "comment_created", "url": "/connect/webhook/comment-created"}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect/installed")
|
||||||
|
async def connect_installed(request: common.Request) -> common.Response:
|
||||||
|
"""Connect install lifecycle: trust-on-first-use (host-gated), verify re-install."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
raise common.HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||||
|
code, detail = await service.process_install(request, body)
|
||||||
|
if code >= 400:
|
||||||
|
raise common.HTTPException(status_code=code, detail=detail)
|
||||||
|
return common.Response(status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect/uninstalled")
|
||||||
|
async def connect_uninstalled(request: common.Request) -> common.Response:
|
||||||
|
"""Connect uninstall lifecycle: verify against the stored secret before deleting."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
raise common.HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||||
|
code, detail = await service.process_uninstall(request, body)
|
||||||
|
if code >= 400:
|
||||||
|
raise common.HTTPException(status_code=code, detail=detail)
|
||||||
|
return common.Response(status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect/webhook/comment-created")
|
||||||
|
async def connect_comment_created(
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""JWT-verified Confluence comment_created trigger."""
|
||||||
|
claims = await common.verify_connect_webhook(request)
|
||||||
|
if claims is None:
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid Connect JWT")
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
background_tasks.add_task(service.process_confluence_comment, payload, claims.get("iss", ""))
|
||||||
|
return {"status": "accepted"}
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
"""GitHub webhook handlers — moved out of webapp.py (behavior-identical).
|
"""GitHub webhook handlers — moved out of common.py (behavior-identical).
|
||||||
|
|
||||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
Helpers and constants stay in common.py; they are accessed through the module
|
||||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
object (``common.X``) so tests that monkeypatch them keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
|
|
||||||
from ..ci_autofix import handle_ci_failure, handle_review_feedback
|
from ..ci_autofix import handle_ci_failure, handle_review_feedback
|
||||||
from ..dashboard.autofix_state import set_pr_autofix_disabled
|
from ..dashboard.autofix_state import set_pr_autofix_disabled
|
||||||
from ..review.findings import FindingInteraction, ReviewerPRMeta, ReviewerSlackThread
|
from ..review.findings import FindingInteraction, ReviewerPRMeta, ReviewerSlackThread
|
||||||
|
|
@ -20,6 +18,7 @@ from ..utils.github_ci import (
|
||||||
)
|
)
|
||||||
from ..utils.github_comments import GitHubAuthError
|
from ..utils.github_comments import GitHubAuthError
|
||||||
from ..utils.slack import GitHubPrRef
|
from ..utils.slack import GitHubPrRef
|
||||||
|
from . import common
|
||||||
|
|
||||||
|
|
||||||
def build_github_issue_prompt(
|
def build_github_issue_prompt(
|
||||||
|
|
@ -37,9 +36,9 @@ def build_github_issue_prompt(
|
||||||
"""Build the user prompt for a GitHub issue-triggered run."""
|
"""Build the user prompt for a GitHub issue-triggered run."""
|
||||||
triggered_by_line = f"## Triggered by: {github_login}\n\n" if github_login else ""
|
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 ""
|
issue_url_line = f"## Issue URL: {issue_url}\n\n" if issue_url else ""
|
||||||
comments_text = webapp._build_github_issue_comments_text(comments)
|
comments_text = common._build_github_issue_comments_text(comments)
|
||||||
sanitized_title = webapp.sanitize_github_comment_body(title)
|
sanitized_title = common.sanitize_github_comment_body(title)
|
||||||
formatted_body = webapp.format_github_comment_body_for_prompt(
|
formatted_body = common.format_github_comment_body_for_prompt(
|
||||||
issue_author or github_login, body
|
issue_author or github_login, body
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,13 +62,13 @@ def build_github_issue_prompt(
|
||||||
|
|
||||||
def build_github_issue_followup_prompt(github_login: str, comment_body: str) -> str:
|
def build_github_issue_followup_prompt(github_login: str, comment_body: str) -> str:
|
||||||
"""Build the prompt for a follow-up GitHub issue comment."""
|
"""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)}"
|
return f"**{github_login}:**\n{common.format_github_comment_body_for_prompt(github_login, comment_body)}"
|
||||||
|
|
||||||
|
|
||||||
def build_github_issue_update_prompt(github_login: str, title: str, body: str) -> str:
|
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."""
|
"""Build the prompt for a follow-up GitHub issue title/body update."""
|
||||||
sanitized_title = webapp.sanitize_github_comment_body(title)
|
sanitized_title = common.sanitize_github_comment_body(title)
|
||||||
formatted_body = webapp.format_github_comment_body_for_prompt(github_login, body)
|
formatted_body = common.format_github_comment_body_for_prompt(github_login, body)
|
||||||
return (
|
return (
|
||||||
f"**{github_login}:** updated the GitHub issue title/body.\n\n"
|
f"**{github_login}:** updated the GitHub issue title/body.\n\n"
|
||||||
f"Title: {sanitized_title}\n\n"
|
f"Title: {sanitized_title}\n\n"
|
||||||
|
|
@ -110,24 +109,24 @@ async def trigger_pr_review_from_ref(
|
||||||
|
|
||||||
# Full token to read PR metadata (privacy/id aren't in the trigger ref);
|
# 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.
|
# 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()
|
app_token, app_token_expires_at = await common.get_github_app_installation_token_with_expiry()
|
||||||
if not app_token:
|
if not app_token:
|
||||||
webapp.logger.warning("No GitHub App token available for PR reviewer request")
|
common.logger.warning("No GitHub App token available for PR reviewer request")
|
||||||
return {"success": False, "error": "No GitHub App token available"}
|
return {"success": False, "error": "No GitHub App token available"}
|
||||||
|
|
||||||
pr_metadata = await webapp.fetch_github_pr_metadata(pr_ref, token=app_token)
|
pr_metadata = await common.fetch_github_pr_metadata(pr_ref, token=app_token)
|
||||||
if not pr_metadata:
|
if not pr_metadata:
|
||||||
return {"success": False, "error": "Could not fetch pull request metadata"}
|
return {"success": False, "error": "Could not fetch pull request metadata"}
|
||||||
|
|
||||||
repo_private = webapp._repo_private_from_pr_metadata(pr_metadata)
|
repo_private = common._repo_private_from_pr_metadata(pr_metadata)
|
||||||
repo_id = webapp._repo_id_from_pr_metadata(pr_metadata)
|
repo_id = common._repo_id_from_pr_metadata(pr_metadata)
|
||||||
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
|
||||||
repo_config,
|
repo_config,
|
||||||
repo_private=repo_private,
|
repo_private=repo_private,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
)
|
)
|
||||||
if not app_token:
|
if not app_token:
|
||||||
webapp.logger.warning("No GitHub App token available for PR reviewer request")
|
common.logger.warning("No GitHub App token available for PR reviewer request")
|
||||||
return {"success": False, "error": "No GitHub App token available"}
|
return {"success": False, "error": "No GitHub App token available"}
|
||||||
|
|
||||||
base_sha = pr_metadata.get("base", {}).get("sha", "")
|
base_sha = pr_metadata.get("base", {}).get("sha", "")
|
||||||
|
|
@ -138,12 +137,12 @@ async def trigger_pr_review_from_ref(
|
||||||
pr_title = pr_metadata.get("title", "")
|
pr_title = pr_metadata.get("title", "")
|
||||||
pr_url = pr_metadata.get("html_url", "") or pr_ref.url
|
pr_url = pr_metadata.get("html_url", "") or pr_ref.url
|
||||||
if not base_sha or not head_sha:
|
if not base_sha or not head_sha:
|
||||||
webapp.logger.warning("Missing base/head SHA for Slack PR review request")
|
common.logger.warning("Missing base/head SHA for Slack PR review request")
|
||||||
return {"success": False, "error": "Pull request metadata is missing base/head SHA"}
|
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)
|
thread_id = common.generate_reviewer_thread_id(pr_ref.owner, pr_ref.repo, pr_ref.number)
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
||||||
return {"success": False, "error": "Could not create reviewer thread"}
|
return {"success": False, "error": "Could not create reviewer thread"}
|
||||||
|
|
||||||
pr_meta: ReviewerPRMeta = {
|
pr_meta: ReviewerPRMeta = {
|
||||||
|
|
@ -162,10 +161,10 @@ async def trigger_pr_review_from_ref(
|
||||||
"channel_id": slack_channel_id,
|
"channel_id": slack_channel_id,
|
||||||
"thread_ts": slack_thread_ts,
|
"thread_ts": slack_thread_ts,
|
||||||
}
|
}
|
||||||
await webapp.set_reviewer_thread_metadata(
|
await common.set_reviewer_thread_metadata(
|
||||||
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta, head_sha=head_sha
|
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta, head_sha=head_sha
|
||||||
)
|
)
|
||||||
await webapp.post_review_started_comment(
|
await common.post_review_started_comment(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
owner=pr_ref.owner,
|
owner=pr_ref.owner,
|
||||||
repo=pr_ref.repo,
|
repo=pr_ref.repo,
|
||||||
|
|
@ -174,7 +173,7 @@ async def trigger_pr_review_from_ref(
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
|
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
|
||||||
configurable = webapp._build_reviewer_configurable(
|
configurable = common._build_reviewer_configurable(
|
||||||
source=source,
|
source=source,
|
||||||
github_login=github_login,
|
github_login=github_login,
|
||||||
github_user_id=github_user_id,
|
github_user_id=github_user_id,
|
||||||
|
|
@ -189,19 +188,19 @@ async def trigger_pr_review_from_ref(
|
||||||
slack_thread_ts=slack_thread_ts,
|
slack_thread_ts=slack_thread_ts,
|
||||||
)
|
)
|
||||||
|
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Dispatching reviewer run for thread %s from %s PR review request", thread_id, source
|
"Dispatching reviewer run for thread %s from %s PR review request", thread_id, source
|
||||||
)
|
)
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
prompt,
|
prompt,
|
||||||
configurable,
|
configurable,
|
||||||
source=source,
|
source=source,
|
||||||
assistant_id="reviewer",
|
assistant_id="reviewer",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
await webapp._store_current_reviewer_run_id(thread_id, run)
|
await common._store_current_reviewer_run_id(thread_id, run)
|
||||||
return {"success": True, "queued": False, "thread_id": thread_id, "pr_url": pr_url}
|
return {"success": True, "queued": False, "thread_id": thread_id, "pr_url": pr_url}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -213,8 +212,8 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
"owner": repo.get("owner", {}).get("login", ""),
|
"owner": repo.get("owner", {}).get("login", ""),
|
||||||
"name": repo.get("name", ""),
|
"name": repo.get("name", ""),
|
||||||
}
|
}
|
||||||
repo_private = webapp._repo_private_from_payload(payload)
|
repo_private = common._repo_private_from_payload(payload)
|
||||||
repo_id = webapp._repo_id_from_payload(payload)
|
repo_id = common._repo_id_from_payload(payload)
|
||||||
pr_number = pull_request.get("number")
|
pr_number = pull_request.get("number")
|
||||||
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
||||||
branch_name = pull_request.get("head", {}).get("ref", "")
|
branch_name = pull_request.get("head", {}).get("ref", "")
|
||||||
|
|
@ -226,10 +225,10 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
github_user_id = payload.get("sender", {}).get("id")
|
github_user_id = payload.get("sender", {}).get("id")
|
||||||
|
|
||||||
if not pr_number or not pr_url or not base_sha or not head_sha:
|
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")
|
common.logger.warning("Missing PR context for reviewer dispatch, skipping run")
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_reviewer_thread_id(
|
thread_id = common.generate_reviewer_thread_id(
|
||||||
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -245,13 +244,13 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
}
|
}
|
||||||
last_reviewed_sha = ""
|
last_reviewed_sha = ""
|
||||||
if payload.get("action") == "ready_for_review":
|
if payload.get("action") == "ready_for_review":
|
||||||
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
metadata = await common._get_thread_metadata_safe(thread_id)
|
||||||
if metadata is not None and metadata.get("kind") == webapp.REVIEWER_THREAD_KIND:
|
if metadata is not None and metadata.get("kind") == common.REVIEWER_THREAD_KIND:
|
||||||
existing_last_reviewed_sha = metadata.get("last_reviewed_sha")
|
existing_last_reviewed_sha = metadata.get("last_reviewed_sha")
|
||||||
if isinstance(existing_last_reviewed_sha, str) and existing_last_reviewed_sha:
|
if isinstance(existing_last_reviewed_sha, str) and existing_last_reviewed_sha:
|
||||||
if existing_last_reviewed_sha == head_sha:
|
if existing_last_reviewed_sha == head_sha:
|
||||||
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
|
await common.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Skipping ready_for_review auto-review for %s/%s#%s: "
|
"Skipping ready_for_review auto-review for %s/%s#%s: "
|
||||||
"head_sha unchanged from last_reviewed_sha",
|
"head_sha unchanged from last_reviewed_sha",
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
|
|
@ -261,30 +260,30 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
return
|
return
|
||||||
last_reviewed_sha = existing_last_reviewed_sha
|
last_reviewed_sha = existing_last_reviewed_sha
|
||||||
|
|
||||||
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
|
||||||
repo_config,
|
repo_config,
|
||||||
repo_private=repo_private,
|
repo_private=repo_private,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
)
|
)
|
||||||
if not app_token:
|
if not app_token:
|
||||||
webapp.logger.warning("No GitHub App token available for reviewer dispatch")
|
common.logger.warning("No GitHub App token available for reviewer dispatch")
|
||||||
return
|
return
|
||||||
|
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
||||||
return
|
return
|
||||||
|
|
||||||
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
await common.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
||||||
|
|
||||||
check_run_id = await webapp.create_review_check_run(
|
check_run_id = await common.create_review_check_run(
|
||||||
owner=repo_config.get("owner", ""),
|
owner=repo_config.get("owner", ""),
|
||||||
repo=repo_config.get("name", ""),
|
repo=repo_config.get("name", ""),
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
token=app_token,
|
token=app_token,
|
||||||
details_url=webapp.dashboard_thread_url(thread_id),
|
details_url=common.dashboard_thread_url(thread_id),
|
||||||
)
|
)
|
||||||
if check_run_id is not None:
|
if check_run_id is not None:
|
||||||
await webapp.set_reviewer_thread_metadata(
|
await common.set_reviewer_thread_metadata(
|
||||||
thread_id, extra={"review_check_run_id": check_run_id}
|
thread_id, extra={"review_check_run_id": check_run_id}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -297,7 +296,7 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
prompt = build_github_pr_review_prompt(repo_config, pr_number, pr_url, base_sha, head_sha)
|
prompt = build_github_pr_review_prompt(repo_config, pr_number, pr_url, base_sha, head_sha)
|
||||||
configurable = webapp._build_reviewer_configurable(
|
configurable = common._build_reviewer_configurable(
|
||||||
source=source,
|
source=source,
|
||||||
github_login=github_login,
|
github_login=github_login,
|
||||||
github_user_id=github_user_id,
|
github_user_id=github_user_id,
|
||||||
|
|
@ -312,18 +311,18 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
last_reviewed_sha=last_reviewed_sha,
|
last_reviewed_sha=last_reviewed_sha,
|
||||||
)
|
)
|
||||||
|
|
||||||
webapp.logger.info("Dispatching reviewer run for thread %s (source=%s)", thread_id, source)
|
common.logger.info("Dispatching reviewer run for thread %s (source=%s)", thread_id, source)
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
prompt,
|
prompt,
|
||||||
configurable,
|
configurable,
|
||||||
source=source,
|
source=source,
|
||||||
assistant_id="reviewer",
|
assistant_id="reviewer",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
await webapp._store_current_reviewer_run_id(thread_id, run)
|
await common._store_current_reviewer_run_id(thread_id, run)
|
||||||
webapp.logger.info("Reviewer run dispatched for thread %s (source=%s)", thread_id, source)
|
common.logger.info("Reviewer run dispatched for thread %s (source=%s)", thread_id, source)
|
||||||
|
|
||||||
|
|
||||||
async def process_github_pr_ready(payload: dict[str, Any]) -> None:
|
async def process_github_pr_ready(payload: dict[str, Any]) -> None:
|
||||||
|
|
@ -337,8 +336,8 @@ async def process_github_pr_ready(payload: dict[str, Any]) -> None:
|
||||||
if is_draft:
|
if is_draft:
|
||||||
author = pull_request.get("user") or {}
|
author = pull_request.get("user") or {}
|
||||||
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
||||||
if not await webapp._draft_review_enabled_for_author(author_login):
|
if not await common._draft_review_enabled_for_author(author_login):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Skipping auto-review of draft PR by %s: review_draft_prs is disabled",
|
"Skipping auto-review of draft PR by %s: review_draft_prs is disabled",
|
||||||
author_login or "<unknown>",
|
author_login or "<unknown>",
|
||||||
)
|
)
|
||||||
|
|
@ -367,13 +366,13 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None:
|
||||||
if not pr_number or not isinstance(pr_number, int):
|
if not pr_number or not isinstance(pr_number, int):
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_reviewer_thread_id(
|
thread_id = common.generate_reviewer_thread_id(
|
||||||
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
||||||
)
|
)
|
||||||
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
metadata = await common._get_thread_metadata_safe(thread_id)
|
||||||
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
|
||||||
# No reviewer thread for this PR, nothing to do.
|
# No reviewer thread for this PR, nothing to do.
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"PR %s/%s#%s closed/reopened: no reviewer thread, skipping watch update",
|
"PR %s/%s#%s closed/reopened: no reviewer thread, skipping watch update",
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
repo_config.get("name"),
|
repo_config.get("name"),
|
||||||
|
|
@ -384,8 +383,8 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None:
|
||||||
if action == "converted_to_draft":
|
if action == "converted_to_draft":
|
||||||
author = pull_request.get("user") or {}
|
author = pull_request.get("user") or {}
|
||||||
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
author_login = author.get("login", "") if isinstance(author, dict) else ""
|
||||||
if await webapp._draft_review_enabled_for_author(author_login):
|
if await common._draft_review_enabled_for_author(author_login):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"PR %s/%s#%s converted to draft but author %s has draft reviews enabled; keeping watch",
|
"PR %s/%s#%s converted to draft but author %s has draft reviews enabled; keeping watch",
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
repo_config.get("name"),
|
repo_config.get("name"),
|
||||||
|
|
@ -398,8 +397,8 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None:
|
||||||
desired_watch = action == "reopened"
|
desired_watch = action == "reopened"
|
||||||
if metadata.get("watch") == desired_watch:
|
if metadata.get("watch") == desired_watch:
|
||||||
return
|
return
|
||||||
await webapp.set_reviewer_thread_metadata(thread_id, watch=desired_watch)
|
await common.set_reviewer_thread_metadata(thread_id, watch=desired_watch)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Set watch=%s on reviewer thread %s after PR %s", desired_watch, thread_id, action
|
"Set watch=%s on reviewer thread %s after PR %s", desired_watch, thread_id, action
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -409,10 +408,10 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
ref = payload.get("ref", "")
|
ref = payload.get("ref", "")
|
||||||
after_sha = payload.get("after", "")
|
after_sha = payload.get("after", "")
|
||||||
if not ref.startswith("refs/heads/"):
|
if not ref.startswith("refs/heads/"):
|
||||||
webapp.logger.debug("Push ignored: ref %s is not a branch", ref)
|
common.logger.debug("Push ignored: ref %s is not a branch", ref)
|
||||||
return
|
return
|
||||||
if not isinstance(after_sha, str) or not after_sha or set(after_sha) == {"0"}:
|
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)
|
common.logger.debug("Push to %s ignored: branch deletion or missing SHA", ref)
|
||||||
return
|
return
|
||||||
head_ref = ref[len("refs/heads/") :]
|
head_ref = ref[len("refs/heads/") :]
|
||||||
|
|
||||||
|
|
@ -421,15 +420,15 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
"owner": repo.get("owner", {}).get("login", "") or repo.get("owner", {}).get("name", ""),
|
"owner": repo.get("owner", {}).get("login", "") or repo.get("owner", {}).get("name", ""),
|
||||||
"name": repo.get("name", ""),
|
"name": repo.get("name", ""),
|
||||||
}
|
}
|
||||||
repo_private = webapp._repo_private_from_payload(payload)
|
repo_private = common._repo_private_from_payload(payload)
|
||||||
repo_id = webapp._repo_id_from_payload(payload)
|
repo_id = common._repo_id_from_payload(payload)
|
||||||
if not repo_config["owner"] or not repo_config["name"]:
|
if not repo_config["owner"] or not repo_config["name"]:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Push to %s ignored: repository owner/name missing from payload", head_ref
|
"Push to %s ignored: repository owner/name missing from payload", head_ref
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not await webapp._is_repo_auto_review_enabled(repo_config):
|
if not await common._is_repo_auto_review_enabled(repo_config):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Push to %s/%s head=%s ignored: automatic review disabled",
|
"Push to %s/%s head=%s ignored: automatic review disabled",
|
||||||
repo_config["owner"],
|
repo_config["owner"],
|
||||||
repo_config["name"],
|
repo_config["name"],
|
||||||
|
|
@ -437,18 +436,18 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
|
||||||
repo_config,
|
repo_config,
|
||||||
repo_private=repo_private,
|
repo_private=repo_private,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
)
|
)
|
||||||
if not app_token:
|
if not app_token:
|
||||||
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
common.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
||||||
return
|
return
|
||||||
|
|
||||||
pr = await webapp._fetch_open_pr_for_branch(repo_config, head_ref, token=app_token)
|
pr = await common._fetch_open_pr_for_branch(repo_config, head_ref, token=app_token)
|
||||||
if not pr:
|
if not pr:
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"No open PR found for push to %s/%s head=%s",
|
"No open PR found for push to %s/%s head=%s",
|
||||||
repo_config["owner"],
|
repo_config["owner"],
|
||||||
repo_config["name"],
|
repo_config["name"],
|
||||||
|
|
@ -460,16 +459,16 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
# If the repo turns out public, re-scope the token so reviewer.py doesn't
|
# If the repo turns out public, re-scope the token so reviewer.py doesn't
|
||||||
# proxy a full-installation token for a public PR.
|
# proxy a full-installation token for a public PR.
|
||||||
if repo_private is None:
|
if repo_private is None:
|
||||||
repo_private = webapp._repo_private_from_pr_metadata(pr)
|
repo_private = common._repo_private_from_pr_metadata(pr)
|
||||||
repo_id = repo_id or webapp._repo_id_from_pr_metadata(pr)
|
repo_id = repo_id or common._repo_id_from_pr_metadata(pr)
|
||||||
if repo_private is False:
|
if repo_private is False:
|
||||||
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
|
||||||
repo_config,
|
repo_config,
|
||||||
repo_private=repo_private,
|
repo_private=repo_private,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
)
|
)
|
||||||
if not app_token:
|
if not app_token:
|
||||||
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
common.logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
||||||
return
|
return
|
||||||
pr_number = pr.get("number")
|
pr_number = pr.get("number")
|
||||||
pr_url = pr.get("html_url") or pr.get("url") or ""
|
pr_url = pr.get("html_url") or pr.get("url") or ""
|
||||||
|
|
@ -478,7 +477,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
head_sha = pr.get("head", {}).get("sha", after_sha)
|
head_sha = pr.get("head", {}).get("sha", after_sha)
|
||||||
pr_title = pr.get("title", "")
|
pr_title = pr.get("title", "")
|
||||||
if not isinstance(pr_number, int) or not base_sha or not head_sha:
|
if not isinstance(pr_number, int) or not base_sha or not head_sha:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Push to %s/%s head=%s ignored: PR metadata missing number/base/head SHA",
|
"Push to %s/%s head=%s ignored: PR metadata missing number/base/head SHA",
|
||||||
repo_config["owner"],
|
repo_config["owner"],
|
||||||
repo_config["name"],
|
repo_config["name"],
|
||||||
|
|
@ -486,12 +485,12 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_reviewer_thread_id(
|
thread_id = common.generate_reviewer_thread_id(
|
||||||
repo_config["owner"], repo_config["name"], pr_number
|
repo_config["owner"], repo_config["name"], pr_number
|
||||||
)
|
)
|
||||||
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
metadata = await common._get_thread_metadata_safe(thread_id)
|
||||||
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Push to %s/%s#%s ignored: no reviewer thread for this PR. "
|
"Push to %s/%s#%s ignored: no reviewer thread for this PR. "
|
||||||
"Trigger a first review (Slack `@open-swe review <url>` or request "
|
"Trigger a first review (Slack `@open-swe review <url>` or request "
|
||||||
"open-swe[bot] as a GitHub reviewer) to start watching.",
|
"open-swe[bot] as a GitHub reviewer) to start watching.",
|
||||||
|
|
@ -501,21 +500,21 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not metadata.get("watch"):
|
if not metadata.get("watch"):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Push to %s ignored: reviewer thread %s is not watching", head_ref, thread_id
|
"Push to %s ignored: reviewer thread %s is not watching", head_ref, thread_id
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
last_reviewed_sha = metadata.get("last_reviewed_sha")
|
last_reviewed_sha = metadata.get("last_reviewed_sha")
|
||||||
if isinstance(last_reviewed_sha, str) and last_reviewed_sha == head_sha:
|
if isinstance(last_reviewed_sha, str) and last_reviewed_sha == head_sha:
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Push to %s ignored: head_sha unchanged from last_reviewed_sha", head_ref
|
"Push to %s ignored: head_sha unchanged from last_reviewed_sha", head_ref
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if (
|
if (
|
||||||
isinstance(last_reviewed_sha, str)
|
isinstance(last_reviewed_sha, str)
|
||||||
and last_reviewed_sha
|
and last_reviewed_sha
|
||||||
and await webapp._is_pr_diff_unchanged_since_last_review(
|
and await common._is_pr_diff_unchanged_since_last_review(
|
||||||
repo_config,
|
repo_config,
|
||||||
base_ref=base_ref,
|
base_ref=base_ref,
|
||||||
last_reviewed_sha=last_reviewed_sha,
|
last_reviewed_sha=last_reviewed_sha,
|
||||||
|
|
@ -523,19 +522,19 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
token=app_token,
|
token=app_token,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
await webapp.set_reviewer_thread_metadata(thread_id, last_reviewed_sha=head_sha)
|
await common.set_reviewer_thread_metadata(thread_id, last_reviewed_sha=head_sha)
|
||||||
# The old head's check disappears once the head moves (GitHub only
|
# 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,
|
# shows checks on the current head), so even though no re-review runs,
|
||||||
# surface a settled check on the new head.
|
# surface a settled check on the new head.
|
||||||
unchanged_check_id = await webapp.create_review_check_run(
|
unchanged_check_id = await common.create_review_check_run(
|
||||||
owner=repo_config["owner"],
|
owner=repo_config["owner"],
|
||||||
repo=repo_config["name"],
|
repo=repo_config["name"],
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
token=app_token,
|
token=app_token,
|
||||||
details_url=webapp.dashboard_thread_url(thread_id),
|
details_url=common.dashboard_thread_url(thread_id),
|
||||||
)
|
)
|
||||||
if unchanged_check_id is not None:
|
if unchanged_check_id is not None:
|
||||||
await webapp.complete_review_check_run(
|
await common.complete_review_check_run(
|
||||||
owner=repo_config["owner"],
|
owner=repo_config["owner"],
|
||||||
repo=repo_config["name"],
|
repo=repo_config["name"],
|
||||||
check_run_id=unchanged_check_id,
|
check_run_id=unchanged_check_id,
|
||||||
|
|
@ -547,26 +546,26 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
f"commit {last_reviewed_sha}."
|
f"commit {last_reviewed_sha}."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Push to %s ignored: PR diff unchanged since last reviewed SHA %s",
|
"Push to %s ignored: PR diff unchanged since last reviewed SHA %s",
|
||||||
head_ref,
|
head_ref,
|
||||||
last_reviewed_sha,
|
last_reviewed_sha,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
threads = await webapp.fetch_pr_review_threads(
|
threads = await common.fetch_pr_review_threads(
|
||||||
owner=repo_config["owner"],
|
owner=repo_config["owner"],
|
||||||
repo=repo_config["name"],
|
repo=repo_config["name"],
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
token=app_token,
|
token=app_token,
|
||||||
)
|
)
|
||||||
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
|
await common.reconcile_findings_with_review_threads(thread_id, threads)
|
||||||
except Exception:
|
except Exception:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Could not sync review threads before push re-review for %s", thread_id
|
"Could not sync review threads before push re-review for %s", thread_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -580,21 +579,21 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
"base_ref": base_ref,
|
"base_ref": base_ref,
|
||||||
"author": (pr.get("user") or {}).get("login", ""),
|
"author": (pr.get("user") or {}).get("login", ""),
|
||||||
}
|
}
|
||||||
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
await common.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
|
# 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
|
# 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;
|
# fresh in-progress check on the new head SHA so the review stays visible;
|
||||||
# publish (or the after-agent hook) settles this id.
|
# publish (or the after-agent hook) settles this id.
|
||||||
check_run_id = await webapp.create_review_check_run(
|
check_run_id = await common.create_review_check_run(
|
||||||
owner=repo_config["owner"],
|
owner=repo_config["owner"],
|
||||||
repo=repo_config["name"],
|
repo=repo_config["name"],
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
token=app_token,
|
token=app_token,
|
||||||
details_url=webapp.dashboard_thread_url(thread_id),
|
details_url=common.dashboard_thread_url(thread_id),
|
||||||
)
|
)
|
||||||
if check_run_id is not None:
|
if check_run_id is not None:
|
||||||
await webapp.set_reviewer_thread_metadata(
|
await common.set_reviewer_thread_metadata(
|
||||||
thread_id, extra={"review_check_run_id": check_run_id}
|
thread_id, extra={"review_check_run_id": check_run_id}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -603,7 +602,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
f"{head_sha}. Reconcile existing findings against the new diff, add any "
|
f"{head_sha}. Reconcile existing findings against the new diff, add any "
|
||||||
f"net-new findings, and call `publish_review` once you're done."
|
f"net-new findings, and call `publish_review` once you're done."
|
||||||
)
|
)
|
||||||
configurable = webapp._build_reviewer_configurable(
|
configurable = common._build_reviewer_configurable(
|
||||||
source="github_push",
|
source="github_push",
|
||||||
github_login=payload.get("sender", {}).get("login", "") or "",
|
github_login=payload.get("sender", {}).get("login", "") or "",
|
||||||
github_user_id=payload.get("sender", {}).get("id"),
|
github_user_id=payload.get("sender", {}).get("id"),
|
||||||
|
|
@ -618,17 +617,17 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
last_reviewed_sha=last_reviewed_sha if isinstance(last_reviewed_sha, str) else "",
|
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)
|
common.logger.info("Dispatching push re-review run for thread %s", thread_id)
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
re_review_prompt,
|
re_review_prompt,
|
||||||
configurable,
|
configurable,
|
||||||
source="github_push",
|
source="github_push",
|
||||||
assistant_id="reviewer",
|
assistant_id="reviewer",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
await webapp._store_current_reviewer_run_id(thread_id, run)
|
await common._store_current_reviewer_run_id(thread_id, run)
|
||||||
|
|
||||||
|
|
||||||
async def process_github_pr_comment(payload: dict[str, Any], event_type: str) -> None:
|
async def process_github_pr_comment(payload: dict[str, Any], event_type: str) -> None:
|
||||||
|
|
@ -650,20 +649,20 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
pr_url,
|
pr_url,
|
||||||
comment_id,
|
comment_id,
|
||||||
node_id,
|
node_id,
|
||||||
) = await webapp.extract_pr_context(payload, event_type)
|
) = await common.extract_pr_context(payload, event_type)
|
||||||
github_user_id = payload.get("sender", {}).get("id")
|
github_user_id = payload.get("sender", {}).get("id")
|
||||||
|
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Processing GitHub PR comment: event=%s, pr=%s, branch=%s",
|
"Processing GitHub PR comment: event=%s, pr=%s, branch=%s",
|
||||||
event_type,
|
event_type,
|
||||||
pr_number,
|
pr_number,
|
||||||
branch_name,
|
branch_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_id = webapp.get_thread_id_from_branch(branch_name) if branch_name else None
|
thread_id = common.get_thread_id_from_branch(branch_name) if branch_name else None
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
if not pr_number:
|
if not pr_number:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Could not determine thread_id for branch '%s' (no pr_number), skipping",
|
"Could not determine thread_id for branch '%s' (no pr_number), skipping",
|
||||||
branch_name,
|
branch_name,
|
||||||
)
|
)
|
||||||
|
|
@ -672,21 +671,21 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
name = repo_config.get("name", "")
|
name = repo_config.get("name", "")
|
||||||
stable_key = f"{owner}/{name}/pr/{pr_number}"
|
stable_key = f"{owner}/{name}/pr/{pr_number}"
|
||||||
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Generated thread_id %s for non-open-swe branch '%s'", thread_id, branch_name
|
"Generated thread_id %s for non-open-swe branch '%s'", thread_id, branch_name
|
||||||
)
|
)
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
try:
|
try:
|
||||||
await langgraph_client.threads.update(thread_id, metadata={"branch_name": branch_name})
|
await langgraph_client.threads.update(thread_id, metadata={"branch_name": branch_name})
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
if webapp._is_not_found_error(exc):
|
if common._is_not_found_error(exc):
|
||||||
await langgraph_client.threads.create(
|
await langgraph_client.threads.create(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
if_exists="do_nothing",
|
if_exists="do_nothing",
|
||||||
metadata={"branch_name": branch_name},
|
metadata={"branch_name": branch_name},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Failed to persist branch_name metadata for thread %s", thread_id
|
"Failed to persist branch_name metadata for thread %s", thread_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -696,28 +695,28 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
# otherwise visible), which would drop a legitimately-mapped user. Mirrors
|
# otherwise visible), which would drop a legitimately-mapped user. Mirrors
|
||||||
# the Slack mention path (process_slack_mention).
|
# the Slack mention path (process_slack_mention).
|
||||||
try:
|
try:
|
||||||
await webapp.refresh_user_mapping_cache()
|
await common.refresh_user_mapping_cache()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Could not refresh user mapping cache for GitHub PR comment", exc_info=True
|
"Could not refresh user mapping cache for GitHub PR comment", exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
email = await webapp.email_for_login(github_login) or ""
|
email = await common.email_for_login(github_login) or ""
|
||||||
if email:
|
if email:
|
||||||
github_token = await webapp._get_or_resolve_thread_github_token(
|
github_token = await common._get_or_resolve_thread_github_token(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
webapp.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
common.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not github_token:
|
if not github_token:
|
||||||
webapp.logger.warning("No GitHub token for thread %s, skipping", thread_id)
|
common.logger.warning("No GitHub token for thread %s, skipping", thread_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if comment_id:
|
if comment_id:
|
||||||
try:
|
try:
|
||||||
await webapp.react_to_github_comment(
|
await common.react_to_github_comment(
|
||||||
repo_config,
|
repo_config,
|
||||||
comment_id,
|
comment_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
|
@ -726,13 +725,13 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
)
|
)
|
||||||
except GitHubAuthError:
|
except GitHubAuthError:
|
||||||
github_token = await webapp._refresh_thread_github_token_after_401(
|
github_token = await common._refresh_thread_github_token_after_401(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
if not github_token:
|
if not github_token:
|
||||||
webapp.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
common.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
||||||
return
|
return
|
||||||
await webapp.react_to_github_comment(
|
await common.react_to_github_comment(
|
||||||
repo_config,
|
repo_config,
|
||||||
comment_id,
|
comment_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
|
@ -742,29 +741,29 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
)
|
)
|
||||||
|
|
||||||
if not pr_number:
|
if not pr_number:
|
||||||
webapp.logger.warning("No PR number found in payload, skipping")
|
common.logger.warning("No PR number found in payload, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
comments = await webapp.fetch_pr_comments_since_last_tag(
|
comments = await common.fetch_pr_comments_since_last_tag(
|
||||||
repo_config, pr_number, token=github_token
|
repo_config, pr_number, token=github_token
|
||||||
)
|
)
|
||||||
except GitHubAuthError:
|
except GitHubAuthError:
|
||||||
github_token = await webapp._refresh_thread_github_token_after_401(
|
github_token = await common._refresh_thread_github_token_after_401(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
if not github_token:
|
if not github_token:
|
||||||
webapp.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
common.logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
||||||
return
|
return
|
||||||
comments = await webapp.fetch_pr_comments_since_last_tag(
|
comments = await common.fetch_pr_comments_since_last_tag(
|
||||||
repo_config, pr_number, token=github_token
|
repo_config, pr_number, token=github_token
|
||||||
)
|
)
|
||||||
if not comments:
|
if not comments:
|
||||||
webapp.logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
|
common.logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
|
||||||
return
|
return
|
||||||
|
|
||||||
prompt = webapp.build_pr_prompt(comments, pr_url, repo_config=repo_config)
|
prompt = common.build_pr_prompt(comments, pr_url, repo_config=repo_config)
|
||||||
await webapp._trigger_or_queue_run(
|
await common._trigger_or_queue_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
prompt,
|
prompt,
|
||||||
github_login=github_login,
|
github_login=github_login,
|
||||||
|
|
@ -776,13 +775,13 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
|
|
||||||
async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
||||||
"""Route replies to Open SWE review comments back to the reviewer graph."""
|
"""Route replies to Open SWE review comments back to the reviewer graph."""
|
||||||
parent_comment_id = webapp._review_comment_reply_parent_id(payload)
|
parent_comment_id = common._review_comment_reply_parent_id(payload)
|
||||||
if parent_comment_id is None:
|
if parent_comment_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
sender = payload.get("sender", {})
|
sender = payload.get("sender", {})
|
||||||
sender_login = sender.get("login") if isinstance(sender, dict) else None
|
sender_login = sender.get("login") if isinstance(sender, dict) else None
|
||||||
if sender_login in webapp.INTERNAL_BOT_LOGINS:
|
if sender_login in common.INTERNAL_BOT_LOGINS:
|
||||||
return
|
return
|
||||||
|
|
||||||
repo = payload.get("repository", {})
|
repo = payload.get("repository", {})
|
||||||
|
|
@ -791,20 +790,20 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
||||||
"owner": repo.get("owner", {}).get("login", ""),
|
"owner": repo.get("owner", {}).get("login", ""),
|
||||||
"name": repo.get("name", ""),
|
"name": repo.get("name", ""),
|
||||||
}
|
}
|
||||||
repo_private = webapp._repo_private_from_payload(payload)
|
repo_private = common._repo_private_from_payload(payload)
|
||||||
repo_id = webapp._repo_id_from_payload(payload)
|
repo_id = common._repo_id_from_payload(payload)
|
||||||
pr_number = pull_request.get("number")
|
pr_number = pull_request.get("number")
|
||||||
if not isinstance(pr_number, int):
|
if not isinstance(pr_number, int):
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_reviewer_thread_id(
|
thread_id = common.generate_reviewer_thread_id(
|
||||||
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
||||||
)
|
)
|
||||||
metadata = await webapp._get_thread_metadata_safe(thread_id)
|
metadata = await common._get_thread_metadata_safe(thread_id)
|
||||||
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
|
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
|
||||||
return
|
return
|
||||||
|
|
||||||
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
|
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
|
||||||
repo_config,
|
repo_config,
|
||||||
repo_private=repo_private,
|
repo_private=repo_private,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
|
|
@ -812,16 +811,16 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
||||||
if not app_token:
|
if not app_token:
|
||||||
return
|
return
|
||||||
|
|
||||||
threads = await webapp.fetch_pr_review_threads(
|
threads = await common.fetch_pr_review_threads(
|
||||||
owner=repo_config["owner"],
|
owner=repo_config["owner"],
|
||||||
repo=repo_config["name"],
|
repo=repo_config["name"],
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
token=app_token,
|
token=app_token,
|
||||||
)
|
)
|
||||||
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
|
await common.reconcile_findings_with_review_threads(thread_id, threads)
|
||||||
findings = await webapp.list_reviewer_findings(thread_id)
|
findings = await common.list_reviewer_findings(thread_id)
|
||||||
finding = next(
|
finding = next(
|
||||||
(item for item in findings if parent_comment_id in webapp._finding_comment_ids(item)), None
|
(item for item in findings if parent_comment_id in common._finding_comment_ids(item)), None
|
||||||
)
|
)
|
||||||
if finding is None:
|
if finding is None:
|
||||||
return
|
return
|
||||||
|
|
@ -846,13 +845,13 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
||||||
else "",
|
else "",
|
||||||
"needs_reassessment": True,
|
"needs_reassessment": True,
|
||||||
}
|
}
|
||||||
await webapp.append_finding_interaction(thread_id, finding_id, interaction)
|
await common.append_finding_interaction(thread_id, finding_id, interaction)
|
||||||
|
|
||||||
base_sha = pull_request.get("base", {}).get("sha", "")
|
base_sha = pull_request.get("base", {}).get("sha", "")
|
||||||
head_sha = pull_request.get("head", {}).get("sha", "")
|
head_sha = pull_request.get("head", {}).get("sha", "")
|
||||||
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
|
||||||
branch_name = pull_request.get("head", {}).get("ref", "")
|
branch_name = pull_request.get("head", {}).get("ref", "")
|
||||||
configurable = webapp._build_reviewer_configurable(
|
configurable = common._build_reviewer_configurable(
|
||||||
source="github_review_comment",
|
source="github_review_comment",
|
||||||
github_login=reply_author,
|
github_login=reply_author,
|
||||||
github_user_id=sender.get("id") if isinstance(sender, dict) else None,
|
github_user_id=sender.get("id") if isinstance(sender, dict) else None,
|
||||||
|
|
@ -873,23 +872,23 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
|
||||||
"finding_reply_body": reply_body,
|
"finding_reply_body": reply_body,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
finding_reply_prompt = webapp._build_queued_finding_reply_prompt(
|
finding_reply_prompt = common._build_queued_finding_reply_prompt(
|
||||||
finding_id=finding_id,
|
finding_id=finding_id,
|
||||||
reply_author=reply_author,
|
reply_author=reply_author,
|
||||||
reply_body=reply_body,
|
reply_body=reply_body,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
)
|
)
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
finding_reply_prompt,
|
finding_reply_prompt,
|
||||||
configurable,
|
configurable,
|
||||||
source="github_review_reply",
|
source="github_review_reply",
|
||||||
assistant_id="reviewer",
|
assistant_id="reviewer",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
await webapp._store_current_reviewer_run_id(thread_id, run)
|
await common._store_current_reviewer_run_id(thread_id, run)
|
||||||
|
|
||||||
|
|
||||||
async def process_github_issue(payload: dict[str, Any], event_type: str) -> None:
|
async def process_github_issue(payload: dict[str, Any], event_type: str) -> None:
|
||||||
|
|
@ -910,7 +909,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
description = issue.get("body") or "No description"
|
description = issue.get("body") or "No description"
|
||||||
issue_author = issue.get("user", {}).get("login", "")
|
issue_author = issue.get("user", {}).get("login", "")
|
||||||
|
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Processing GitHub issue: event=%s, issue=%s, repo=%s/%s",
|
"Processing GitHub issue: event=%s, issue=%s, repo=%s/%s",
|
||||||
event_type,
|
event_type,
|
||||||
issue_number,
|
issue_number,
|
||||||
|
|
@ -919,66 +918,66 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
)
|
)
|
||||||
|
|
||||||
if not issue_id or not issue_number:
|
if not issue_id or not issue_number:
|
||||||
webapp.logger.warning("Missing GitHub issue id/number, skipping")
|
common.logger.warning("Missing GitHub issue id/number, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Refresh the per-process user-mapping cache from the Store before
|
# Refresh the per-process user-mapping cache from the Store before
|
||||||
# resolving the author's email (multi-replica staleness; mirrors the Slack
|
# resolving the author's email (multi-replica staleness; mirrors the Slack
|
||||||
# mention path in process_slack_mention).
|
# mention path in process_slack_mention).
|
||||||
try:
|
try:
|
||||||
await webapp.refresh_user_mapping_cache()
|
await common.refresh_user_mapping_cache()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug("Could not refresh user mapping cache for GitHub issue", exc_info=True)
|
common.logger.debug("Could not refresh user mapping cache for GitHub issue", exc_info=True)
|
||||||
|
|
||||||
email = await webapp.email_for_login(github_login) or ""
|
email = await common.email_for_login(github_login) or ""
|
||||||
if not email:
|
if not email:
|
||||||
webapp.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
common.logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_github_issue(issue_id)
|
thread_id = common.generate_thread_id_from_github_issue(issue_id)
|
||||||
existing_thread = await webapp._thread_exists(thread_id)
|
existing_thread = await common._thread_exists(thread_id)
|
||||||
github_token = await webapp._get_or_resolve_thread_github_token(
|
github_token = await common._get_or_resolve_thread_github_token(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
app_token = await webapp.get_github_app_installation_token()
|
app_token = await common.get_github_app_installation_token()
|
||||||
reaction_token = github_token or app_token
|
reaction_token = github_token or app_token
|
||||||
comment = payload.get("comment", {})
|
comment = payload.get("comment", {})
|
||||||
comment_id = comment.get("id")
|
comment_id = comment.get("id")
|
||||||
if event_type == "issue_comment" and comment_id:
|
if event_type == "issue_comment" and comment_id:
|
||||||
if not reaction_token:
|
if not reaction_token:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"No GitHub token available to react to issue comment %s", comment_id
|
"No GitHub token available to react to issue comment %s", comment_id
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
reacted = await webapp.react_to_github_comment(
|
reacted = await common.react_to_github_comment(
|
||||||
repo_config,
|
repo_config,
|
||||||
comment_id,
|
comment_id,
|
||||||
event_type="issue_comment",
|
event_type="issue_comment",
|
||||||
token=reaction_token,
|
token=reaction_token,
|
||||||
)
|
)
|
||||||
except GitHubAuthError:
|
except GitHubAuthError:
|
||||||
github_token = await webapp._refresh_thread_github_token_after_401(
|
github_token = await common._refresh_thread_github_token_after_401(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
reaction_token = github_token or app_token
|
reaction_token = github_token or app_token
|
||||||
reacted = False
|
reacted = False
|
||||||
if reaction_token:
|
if reaction_token:
|
||||||
try:
|
try:
|
||||||
reacted = await webapp.react_to_github_comment(
|
reacted = await common.react_to_github_comment(
|
||||||
repo_config,
|
repo_config,
|
||||||
comment_id,
|
comment_id,
|
||||||
event_type="issue_comment",
|
event_type="issue_comment",
|
||||||
token=reaction_token,
|
token=reaction_token,
|
||||||
)
|
)
|
||||||
except GitHubAuthError:
|
except GitHubAuthError:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Re-auth still produced 401 reacting to issue comment %s",
|
"Re-auth still produced 401 reacting to issue comment %s",
|
||||||
comment_id,
|
comment_id,
|
||||||
)
|
)
|
||||||
reacted = False
|
reacted = False
|
||||||
if not reacted:
|
if not reacted:
|
||||||
webapp.logger.warning("Failed to react to GitHub issue comment %s", comment_id)
|
common.logger.warning("Failed to react to GitHub issue comment %s", comment_id)
|
||||||
|
|
||||||
if existing_thread:
|
if existing_thread:
|
||||||
if event_type == "issue_comment":
|
if event_type == "issue_comment":
|
||||||
|
|
@ -990,14 +989,14 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
prompt = build_github_issue_update_prompt(github_login, title, description)
|
prompt = build_github_issue_update_prompt(github_login, title, description)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
comments = await webapp.fetch_issue_comments(
|
comments = await common.fetch_issue_comments(
|
||||||
repo_config, issue_number, token=github_token or app_token
|
repo_config, issue_number, token=github_token or app_token
|
||||||
)
|
)
|
||||||
except GitHubAuthError:
|
except GitHubAuthError:
|
||||||
github_token = await webapp._refresh_thread_github_token_after_401(
|
github_token = await common._refresh_thread_github_token_after_401(
|
||||||
thread_id, email, repo=repo_config
|
thread_id, email, repo=repo_config
|
||||||
)
|
)
|
||||||
comments = await webapp.fetch_issue_comments(
|
comments = await common.fetch_issue_comments(
|
||||||
repo_config, issue_number, token=github_token or app_token
|
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):
|
if comment_id and not any(item.get("comment_id") == comment_id for item in comments):
|
||||||
|
|
@ -1035,7 +1034,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="github",
|
source="github",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -1044,17 +1043,17 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
source_context={"github_issue": configurable["github_issue"]},
|
source_context={"github_issue": configurable["github_issue"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
webapp.logger.info("Dispatching LangGraph run for thread %s from GitHub issue", thread_id)
|
common.logger.info("Dispatching LangGraph run for thread %s from GitHub issue", thread_id)
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
await webapp.dispatch_agent_run(
|
await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
prompt,
|
prompt,
|
||||||
configurable,
|
configurable,
|
||||||
source="github_issue",
|
source="github_issue",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
webapp.logger.info("LangGraph run dispatched for thread %s from GitHub issue", thread_id)
|
common.logger.info("LangGraph run dispatched for thread %s from GitHub issue", thread_id)
|
||||||
|
|
||||||
|
|
||||||
# ---- CI auto-fix handlers (re-wired onto the modular dispatch model) ----
|
# ---- CI auto-fix handlers (re-wired onto the modular dispatch model) ----
|
||||||
|
|
@ -1081,7 +1080,7 @@ async def process_github_ci_event(payload: dict[str, Any], event_type: str) -> N
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
source="github_ci",
|
source="github_ci",
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"CI auto-fix for %s/%s@%s (%s): %s",
|
"CI auto-fix for %s/%s@%s (%s): %s",
|
||||||
repo_config["owner"],
|
repo_config["owner"],
|
||||||
repo_config["name"],
|
repo_config["name"],
|
||||||
|
|
@ -1100,7 +1099,7 @@ def _parse_autofix_command(comment_body: str) -> bool | None:
|
||||||
Returns ``None`` when the comment isn't an auto-fix command. Requires an
|
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.
|
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):
|
if not any(tag in comment_body.lower() for tag in common.OPEN_SWE_TAGS):
|
||||||
return None
|
return None
|
||||||
match = _AUTOFIX_COMMAND_RE.search(comment_body)
|
match = _AUTOFIX_COMMAND_RE.search(comment_body)
|
||||||
if not match:
|
if not match:
|
||||||
|
|
@ -1135,7 +1134,7 @@ async def process_github_autofix_command(
|
||||||
if ref is None:
|
if ref is None:
|
||||||
return
|
return
|
||||||
await set_pr_autofix_disabled(ref["owner"], ref["name"], ref["number"], disabled)
|
await set_pr_autofix_disabled(ref["owner"], ref["name"], ref["number"], disabled)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Auto-fix %s for %s/%s#%s via comment",
|
"Auto-fix %s for %s/%s#%s via comment",
|
||||||
"disabled" if disabled else "enabled",
|
"disabled" if disabled else "enabled",
|
||||||
ref["owner"],
|
ref["owner"],
|
||||||
|
|
@ -1146,11 +1145,11 @@ async def process_github_autofix_command(
|
||||||
comment_id = comment.get("id")
|
comment_id = comment.get("id")
|
||||||
if not isinstance(comment_id, int):
|
if not isinstance(comment_id, int):
|
||||||
return
|
return
|
||||||
token = await webapp.get_github_app_installation_token()
|
token = await common.get_github_app_installation_token()
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await webapp.react_to_github_comment(
|
await common.react_to_github_comment(
|
||||||
{"owner": ref["owner"], "name": ref["name"]},
|
{"owner": ref["owner"], "name": ref["name"]},
|
||||||
comment_id,
|
comment_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
|
@ -1159,7 +1158,7 @@ async def process_github_autofix_command(
|
||||||
node_id=comment.get("node_id"),
|
node_id=comment.get("node_id"),
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug("Failed to react to auto-fix command comment", exc_info=True)
|
common.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
|
# GitHub author_association values that imply at least repo-member trust. Used
|
||||||
|
|
@ -1192,7 +1191,7 @@ def _is_actionable_review_payload(payload: dict[str, Any], event_type: str) -> b
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return False
|
return False
|
||||||
reviewer = (node.get("user") or {}).get("login", "")
|
reviewer = (node.get("user") or {}).get("login", "")
|
||||||
if reviewer in webapp.INTERNAL_BOT_LOGINS:
|
if reviewer in common.INTERNAL_BOT_LOGINS:
|
||||||
return False
|
return False
|
||||||
if node.get("author_association") not in _TRUSTED_REVIEW_ASSOCIATIONS:
|
if node.get("author_association") not in _TRUSTED_REVIEW_ASSOCIATIONS:
|
||||||
return False
|
return False
|
||||||
|
|
@ -1208,7 +1207,7 @@ async def process_github_autofix_review(payload: dict[str, Any], event_type: str
|
||||||
comment = payload.get("comment") or payload.get("review", {})
|
comment = payload.get("comment") or payload.get("review", {})
|
||||||
reviewer = (comment.get("user") or {}).get("login", "") if isinstance(comment, dict) else ""
|
reviewer = (comment.get("user") or {}).get("login", "") if isinstance(comment, dict) else ""
|
||||||
body = (comment.get("body") or "") 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:
|
if not body.strip() or reviewer in common.INTERNAL_BOT_LOGINS:
|
||||||
return
|
return
|
||||||
result = await handle_review_feedback(
|
result = await handle_review_feedback(
|
||||||
repo_config={"owner": ref["owner"], "name": ref["name"]},
|
repo_config={"owner": ref["owner"], "name": ref["name"]},
|
||||||
|
|
@ -1218,7 +1217,7 @@ async def process_github_autofix_review(payload: dict[str, Any], event_type: str
|
||||||
body=body,
|
body=body,
|
||||||
source="github_review",
|
source="github_review",
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Auto-fix review feedback for %s/%s#%s: %s",
|
"Auto-fix review feedback for %s/%s#%s: %s",
|
||||||
ref["owner"],
|
ref["owner"],
|
||||||
ref["name"],
|
ref["name"],
|
||||||
|
|
|
||||||
198
agent/webhooks/github_routes.py
Normal file
198
agent/webhooks/github_routes.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"""Github webhook HTTP routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import github as service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/github")
|
||||||
|
async def github_webhook(
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Handle GitHub webhooks for issue and PR events that tag @open-swe."""
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
signature = request.headers.get("X-Hub-Signature-256", "")
|
||||||
|
if not common.verify_github_signature(body, signature, secret=common.GITHUB_WEBHOOK_SECRET):
|
||||||
|
common.logger.warning("Invalid GitHub webhook signature")
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
event_type = request.headers.get("X-GitHub-Event", "")
|
||||||
|
if event_type not in common._SUPPORTED_GH_EVENTS:
|
||||||
|
common.logger.info("Ignoring unsupported GitHub event type: %s", event_type)
|
||||||
|
return {"status": "ignored", "reason": f"Unsupported event type: {event_type}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = common.json.loads(body)
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
common.logger.exception("Failed to parse GitHub webhook JSON")
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
|
||||||
|
webhook_repo = payload.get("repository", {})
|
||||||
|
webhook_repo_config = {
|
||||||
|
"owner": webhook_repo.get("owner", {}).get("login", ""),
|
||||||
|
"name": webhook_repo.get("name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
issue = payload.get("issue", {})
|
||||||
|
is_pull_request_comment = bool(event_type == "issue_comment" and issue.get("pull_request"))
|
||||||
|
is_issue_comment = bool(event_type == "issue_comment" and not issue.get("pull_request"))
|
||||||
|
is_issue_event = event_type == "issues"
|
||||||
|
is_pull_request_event = event_type == "pull_request"
|
||||||
|
|
||||||
|
if is_pull_request_event:
|
||||||
|
action = payload.get("action", "")
|
||||||
|
if action not in common._SUPPORTED_GH_PULL_REQUEST_ACTIONS:
|
||||||
|
common.logger.info("Ignoring unsupported GitHub pull_request action: %s", action)
|
||||||
|
return {
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": f"Unsupported GitHub pull_request action: {action}",
|
||||||
|
}
|
||||||
|
if action in common._GH_PR_AGENT_STATE_ACTIONS:
|
||||||
|
background_tasks.add_task(common.update_agent_thread_pr_state, payload)
|
||||||
|
if action in common._GH_PR_WATCH_TOGGLE_ACTIONS:
|
||||||
|
common.logger.info(
|
||||||
|
"Accepted GitHub PR %s webhook, scheduling reviewer watch update", action
|
||||||
|
)
|
||||||
|
background_tasks.add_task(service.process_github_pr_close, payload)
|
||||||
|
return {"status": "accepted", "message": f"Processing PR {action} for reviewer watch"}
|
||||||
|
if action in common._GH_PR_FIRST_REVIEW_ACTIONS:
|
||||||
|
if not await common._is_repo_auto_review_enabled(webhook_repo_config):
|
||||||
|
return {"status": "ignored", "reason": "Automatic review disabled for repository"}
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, "pull_request")
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
common.logger.info("Accepted GitHub PR %s webhook, scheduling auto-review task", action)
|
||||||
|
background_tasks.add_task(service.process_github_pr_ready, payload)
|
||||||
|
return {"status": "accepted", "message": f"Processing PR {action} for auto-review"}
|
||||||
|
common.logger.info("Ignoring unsupported GitHub pull_request action: %s", action)
|
||||||
|
return {
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": f"Unsupported GitHub pull_request action: {action}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_type == "push":
|
||||||
|
if not await common._is_repo_auto_review_enabled(webhook_repo_config):
|
||||||
|
return {"status": "ignored", "reason": "Automatic review disabled for repository"}
|
||||||
|
common.logger.info("Accepted GitHub push webhook, scheduling reviewer watch evaluation")
|
||||||
|
background_tasks.add_task(service.process_github_push_event, payload)
|
||||||
|
return {"status": "accepted", "message": "Processing GitHub push for reviewer watch"}
|
||||||
|
|
||||||
|
if event_type in common._GH_CI_EVENTS:
|
||||||
|
if not common.is_failing_ci_payload(payload, event_type):
|
||||||
|
return {"status": "ignored", "reason": "CI event is not a completed failure"}
|
||||||
|
if not await common._is_repo_auto_review_enabled(webhook_repo_config):
|
||||||
|
return {"status": "ignored", "reason": "Automatic review disabled for repository"}
|
||||||
|
common.logger.info(
|
||||||
|
"Accepted GitHub %s webhook, scheduling CI auto-fix evaluation", event_type
|
||||||
|
)
|
||||||
|
background_tasks.add_task(service.process_github_ci_event, payload, event_type)
|
||||||
|
return {"status": "accepted", "message": f"Processing GitHub {event_type} for auto-fix"}
|
||||||
|
|
||||||
|
if not common._is_repo_allowed(webhook_repo_config):
|
||||||
|
common.logger.debug(
|
||||||
|
"Rejecting GitHub webhook: repo '%s/%s' not in allowlist",
|
||||||
|
webhook_repo_config.get("owner"),
|
||||||
|
webhook_repo_config.get("name"),
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "Repository not in allowlist"}
|
||||||
|
|
||||||
|
if is_issue_event:
|
||||||
|
action = payload.get("action", "")
|
||||||
|
if action not in common._SUPPORTED_GH_ISSUE_ACTIONS:
|
||||||
|
common.logger.info("Ignoring unsupported GitHub issue action: %s", action)
|
||||||
|
return {"status": "ignored", "reason": f"Unsupported GitHub issue action: {action}"}
|
||||||
|
if action == "edited":
|
||||||
|
changes = payload.get("changes", {})
|
||||||
|
if not any(field in changes for field in ("body", "title")):
|
||||||
|
common.logger.info("Ignoring GitHub issue edit without title/body changes")
|
||||||
|
return {"status": "ignored", "reason": "Issue edit did not change title or body"}
|
||||||
|
|
||||||
|
issue_text = f"{issue.get('title', '')}\n\n{issue.get('body', '')}".lower()
|
||||||
|
if not any(tag in issue_text for tag in common.OPEN_SWE_TAGS):
|
||||||
|
common.logger.info("Ignoring issue that does not mention @openswe or @open-swe")
|
||||||
|
return {"status": "ignored", "reason": "Issue does not mention @openswe or @open-swe"}
|
||||||
|
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, event_type)
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
|
||||||
|
common.logger.info("Accepted GitHub issue webhook, scheduling background task")
|
||||||
|
background_tasks.add_task(service.process_github_issue, payload, event_type)
|
||||||
|
return {"status": "accepted", "message": "Processing GitHub issue event"}
|
||||||
|
|
||||||
|
action = payload.get("action", "")
|
||||||
|
supported_comment_actions = common._SUPPORTED_GH_COMMENT_ACTIONS.get(event_type)
|
||||||
|
if supported_comment_actions is None:
|
||||||
|
common.logger.info("Ignoring unsupported GitHub payload shape for event=%s", event_type)
|
||||||
|
return {"status": "ignored", "reason": f"Unsupported payload for event type: {event_type}"}
|
||||||
|
if action and action not in supported_comment_actions:
|
||||||
|
common.logger.debug("Ignoring unsupported GitHub %s action: %s", event_type, action)
|
||||||
|
return {"status": "ignored", "reason": f"Unsupported GitHub {event_type} action: {action}"}
|
||||||
|
|
||||||
|
comment = payload.get("comment") or payload.get("review", {})
|
||||||
|
comment_body = (comment.get("body") or "") if comment else ""
|
||||||
|
|
||||||
|
is_pr_related_comment = is_pull_request_comment or event_type in {
|
||||||
|
"pull_request_review_comment",
|
||||||
|
"pull_request_review",
|
||||||
|
}
|
||||||
|
autofix_command = service._parse_autofix_command(comment_body)
|
||||||
|
if autofix_command is not None and is_pr_related_comment:
|
||||||
|
if not await common._is_repo_auto_review_enabled(webhook_repo_config):
|
||||||
|
return {"status": "ignored", "reason": "Automatic review disabled for repository"}
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, event_type)
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
background_tasks.add_task(
|
||||||
|
service.process_github_autofix_command, payload, event_type, disabled=autofix_command
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Processing auto-fix toggle"}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event_type == "pull_request_review_comment"
|
||||||
|
and common._review_comment_reply_parent_id(payload) is not None
|
||||||
|
):
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, event_type)
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
background_tasks.add_task(service.process_github_review_finding_reply, payload)
|
||||||
|
return {"status": "accepted", "message": "Processing review finding reply"}
|
||||||
|
|
||||||
|
if not any(tag in comment_body.lower() for tag in common.OPEN_SWE_TAGS):
|
||||||
|
if service._is_actionable_review_payload(
|
||||||
|
payload, event_type
|
||||||
|
) and await common._is_repo_auto_review_enabled(webhook_repo_config):
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, event_type)
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
background_tasks.add_task(service.process_github_autofix_review, payload, event_type)
|
||||||
|
return {"status": "accepted", "message": "Processing auto-fix review feedback"}
|
||||||
|
common.logger.debug(
|
||||||
|
"Ignoring GitHub %s%s that does not mention @openswe or @open-swe",
|
||||||
|
event_type,
|
||||||
|
f" action={action}" if action else "",
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "Comment does not mention @openswe or @open-swe"}
|
||||||
|
|
||||||
|
gate_rejection = await common._enforce_public_repo_org_gate(payload, event_type)
|
||||||
|
if gate_rejection is not None:
|
||||||
|
return gate_rejection
|
||||||
|
|
||||||
|
common.logger.info("Accepted GitHub webhook: event=%s, scheduling background task", event_type)
|
||||||
|
if is_pull_request_comment or event_type in {
|
||||||
|
"pull_request_review_comment",
|
||||||
|
"pull_request_review",
|
||||||
|
}:
|
||||||
|
background_tasks.add_task(service.process_github_pr_comment, payload, event_type)
|
||||||
|
return {"status": "accepted", "message": f"Processing {event_type} event"}
|
||||||
|
|
||||||
|
if is_issue_comment:
|
||||||
|
background_tasks.add_task(service.process_github_issue, payload, event_type)
|
||||||
|
return {"status": "accepted", "message": "Processing GitHub issue comment event"}
|
||||||
|
|
||||||
|
common.logger.info("Ignoring unsupported GitHub payload shape for event=%s", event_type)
|
||||||
|
return {"status": "ignored", "reason": f"Unsupported payload for event type: {event_type}"}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Jira webhook handler — mirrors ``agent/webhooks/linear.py`` for Jira issues.
|
"""Jira webhook handler — mirrors ``agent/webhooks/linear.py`` for Jira issues.
|
||||||
|
|
||||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
Helpers and constants stay in common.py; they are accessed through the module
|
||||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
object (``common.X``) so tests that monkeypatch them keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||||
import httpx
|
import httpx
|
||||||
from langchain_core.messages.content import create_text_block
|
from langchain_core.messages.content import create_text_block
|
||||||
|
|
||||||
from agent import webapp
|
from . import common
|
||||||
|
|
||||||
|
|
||||||
async def process_jira_issue( # noqa: PLR0912, PLR0915
|
async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
|
|
@ -20,20 +20,20 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
issue_data: The Jira issue data from the webhook (basic info + the
|
issue_data: The Jira issue data from the webhook (basic info + the
|
||||||
triggering comment; see ``webapp.jira_webhook`` for the shape).
|
triggering comment; see ``jira_routes.jira_webhook`` for the shape).
|
||||||
repo_config: The repo configuration with owner and name.
|
repo_config: The repo configuration with owner and name.
|
||||||
"""
|
"""
|
||||||
issue_key = issue_data.get("key", "")
|
issue_key = issue_data.get("key", "")
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Processing Jira issue %s for repo %s/%s",
|
"Processing Jira issue %s for repo %s/%s",
|
||||||
issue_key,
|
issue_key,
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
repo_config.get("name"),
|
repo_config.get("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_jira_issue(issue_key)
|
thread_id = common.generate_thread_id_from_jira_issue(issue_key)
|
||||||
|
|
||||||
full_issue = await webapp.fetch_jira_issue_details(issue_key)
|
full_issue = await common.fetch_jira_issue_details(issue_key)
|
||||||
if not full_issue:
|
if not full_issue:
|
||||||
full_issue = {}
|
full_issue = {}
|
||||||
|
|
||||||
|
|
@ -46,26 +46,26 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
user_name = comment_author.get("name") or None
|
user_name = comment_author.get("name") or None
|
||||||
|
|
||||||
user_email = actor_email
|
user_email = actor_email
|
||||||
webapp.logger.info("User email for issue %s: %s", issue_key, user_email)
|
common.logger.info("User email for issue %s: %s", issue_key, user_email)
|
||||||
|
|
||||||
title = full_issue.get("title") or "No title"
|
title = full_issue.get("title") or "No title"
|
||||||
description = full_issue.get("description") or "No description"
|
description = full_issue.get("description") or "No description"
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
description_image_urls = webapp.extract_image_urls(description)
|
description_image_urls = common.extract_image_urls(description)
|
||||||
if description_image_urls:
|
if description_image_urls:
|
||||||
image_urls.extend(description_image_urls)
|
image_urls.extend(description_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in issue description",
|
"Found %d image URL(s) in issue description",
|
||||||
len(description_image_urls),
|
len(description_image_urls),
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_comments = await webapp.fetch_jira_issue_comments(issue_key)
|
raw_comments = await common.fetch_jira_issue_comments(issue_key)
|
||||||
comments = [{**comment, "createdAt": comment.get("created", "")} for comment in raw_comments]
|
comments = [{**comment, "createdAt": comment.get("created", "")} for comment in raw_comments]
|
||||||
comments_text = ""
|
comments_text = ""
|
||||||
triggering_comment = issue_data.get("triggering_comment", "")
|
triggering_comment = issue_data.get("triggering_comment", "")
|
||||||
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
||||||
|
|
||||||
bot_message_prefixes = webapp._GITHUB_BOT_MESSAGE_PREFIXES
|
bot_message_prefixes = common._GITHUB_BOT_MESSAGE_PREFIXES
|
||||||
|
|
||||||
comment_ids: set[str] = set()
|
comment_ids: set[str] = set()
|
||||||
comment_id_to_index: dict[str, int] = {}
|
comment_id_to_index: dict[str, int] = {}
|
||||||
|
|
@ -82,22 +82,22 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
trigger_index = comment_id_to_index.get(triggering_comment_id)
|
trigger_index = comment_id_to_index.get(triggering_comment_id)
|
||||||
if trigger_index is not None:
|
if trigger_index is not None:
|
||||||
relevant_comments = comments[trigger_index:]
|
relevant_comments = comments[trigger_index:]
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Using triggering comment index %d to build relevant comments",
|
"Using triggering comment index %d to build relevant comments",
|
||||||
trigger_index,
|
trigger_index,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
relevant_comments = webapp.get_recent_comments(comments, bot_message_prefixes)
|
relevant_comments = common.get_recent_comments(comments, bot_message_prefixes)
|
||||||
|
|
||||||
if relevant_comments:
|
if relevant_comments:
|
||||||
comments_text = "\n\n## Comments:\n"
|
comments_text = "\n\n## Comments:\n"
|
||||||
for comment in relevant_comments:
|
for comment in relevant_comments:
|
||||||
author = (comment.get("author") or {}).get("name") or "User"
|
author = (comment.get("author") or {}).get("name") or "User"
|
||||||
body = comment.get("body", "")
|
body = comment.get("body", "")
|
||||||
body_image_urls = webapp.extract_image_urls(body)
|
body_image_urls = common.extract_image_urls(body)
|
||||||
if body_image_urls:
|
if body_image_urls:
|
||||||
image_urls.extend(body_image_urls)
|
image_urls.extend(body_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in comment by %s",
|
"Found %d image URL(s) in comment by %s",
|
||||||
len(body_image_urls),
|
len(body_image_urls),
|
||||||
author,
|
author,
|
||||||
|
|
@ -111,16 +111,16 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
comments_text = "\n\n## Comments:\n"
|
comments_text = "\n\n## Comments:\n"
|
||||||
trigger_author = comment_author.get("name") or "Unknown"
|
trigger_author = comment_author.get("name") or "Unknown"
|
||||||
trigger_body = triggering_comment
|
trigger_body = triggering_comment
|
||||||
trigger_image_urls = webapp.extract_image_urls(trigger_body)
|
trigger_image_urls = common.extract_image_urls(trigger_body)
|
||||||
if trigger_image_urls:
|
if trigger_image_urls:
|
||||||
image_urls.extend(trigger_image_urls)
|
image_urls.extend(trigger_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in triggering comment by %s",
|
"Found %d image URL(s) in triggering comment by %s",
|
||||||
len(trigger_image_urls),
|
len(trigger_image_urls),
|
||||||
trigger_author,
|
trigger_author,
|
||||||
)
|
)
|
||||||
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
|
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Appended triggering comment %s not present in issue comments list",
|
"Appended triggering comment %s not present in issue comments list",
|
||||||
triggering_comment_id or "<missing-id>",
|
triggering_comment_id or "<missing-id>",
|
||||||
)
|
)
|
||||||
|
|
@ -152,22 +152,22 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
# user-mapping store Slack/Linear use, so PRs open *as the triggering user*
|
# user-mapping store Slack/Linear use, so PRs open *as the triggering user*
|
||||||
# and the thread is tagged for the dashboard. Restricted to the comment
|
# and the thread is tagged for the dashboard. Restricted to the comment
|
||||||
# author so token attribution never falls back to reporter/assignee.
|
# author so token attribution never falls back to reporter/assignee.
|
||||||
mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None
|
mapped_login = await common.resolve_login_from_email_async(actor_email) if actor_email else None
|
||||||
# Only attribute to an *active* user mapping; a pending/unconfirmed mapping
|
# Only attribute to an *active* user mapping; a pending/unconfirmed mapping
|
||||||
# must never drive PR authorship or token resolution.
|
# must never drive PR authorship or token resolution.
|
||||||
if mapped_login and not webapp.is_login_mapped(mapped_login):
|
if mapped_login and not common.is_login_mapped(mapped_login):
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Jira actor login %s is not an active mapping; running unattributed", mapped_login
|
"Jira actor login %s is not an active mapping; running unattributed", mapped_login
|
||||||
)
|
)
|
||||||
mapped_login = None
|
mapped_login = None
|
||||||
|
|
||||||
image_model_override: tuple[str, str] | None = None
|
image_model_override: tuple[str, str] | None = None
|
||||||
if image_urls:
|
if image_urls:
|
||||||
image_urls = webapp.dedupe_urls(image_urls)
|
image_urls = common.dedupe_urls(image_urls)
|
||||||
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
|
||||||
if not webapp.model_supports_images(resolved_model_id):
|
if not common.model_supports_images(resolved_model_id):
|
||||||
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
|
fallback_model_id, fallback_effort = common.default_vision_model_pair()
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Using vision fallback model %s for %d Jira image(s); configured model %s "
|
"Using vision fallback model %s for %d Jira image(s); configured model %s "
|
||||||
"does not support images",
|
"does not support images",
|
||||||
fallback_model_id,
|
fallback_model_id,
|
||||||
|
|
@ -176,15 +176,15 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
)
|
)
|
||||||
resolved_model_id = fallback_model_id
|
resolved_model_id = fallback_model_id
|
||||||
image_model_override = (fallback_model_id, fallback_effort)
|
image_model_override = (fallback_model_id, fallback_effort)
|
||||||
webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
|
common.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
|
||||||
webapp.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
|
common.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=common.DEFAULT_HTTP_TIMEOUT) as client:
|
||||||
for image_url in image_urls:
|
for image_url in image_urls:
|
||||||
image_block = await webapp.fetch_image_block(image_url, client)
|
image_block = await common.fetch_image_block(image_url, client)
|
||||||
if image_block:
|
if image_block:
|
||||||
content_blocks.append(image_block)
|
content_blocks.append(image_block)
|
||||||
webapp.logger.info("Built %d content block(s) for prompt", len(content_blocks))
|
common.logger.info("Built %d content block(s) for prompt", len(content_blocks))
|
||||||
|
|
||||||
configurable: dict[str, Any] = {
|
configurable: dict[str, Any] = {
|
||||||
"repo": repo_config,
|
"repo": repo_config,
|
||||||
|
|
@ -205,7 +205,7 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
configurable["agent_model_id"] = image_model_override[0]
|
configurable["agent_model_id"] = image_model_override[0]
|
||||||
configurable["agent_effort"] = image_model_override[1]
|
configurable["agent_effort"] = image_model_override[1]
|
||||||
|
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="jira",
|
source="jira",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -215,16 +215,16 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
|
||||||
source_context={"jira_issue": configurable["jira_issue"]},
|
source_context={"jira_issue": configurable["jira_issue"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
content_blocks,
|
content_blocks,
|
||||||
configurable,
|
configurable,
|
||||||
source="jira",
|
source="jira",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"LangGraph run dispatched for thread %s (run=%s)",
|
"LangGraph run dispatched for thread %s (run=%s)",
|
||||||
thread_id,
|
thread_id,
|
||||||
run.get("run_id") if isinstance(run, dict) else None,
|
run.get("run_id") if isinstance(run, dict) else None,
|
||||||
)
|
)
|
||||||
await webapp.post_jira_trace_comment(issue_key, thread_id)
|
await common.post_jira_trace_comment(issue_key, thread_id)
|
||||||
|
|
|
||||||
182
agent/webhooks/jira_routes.py
Normal file
182
agent/webhooks/jira_routes.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""Jira webhook HTTP routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import jira as service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/jira")
|
||||||
|
async def jira_webhook( # noqa: PLR0911, PLR0912
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Handle Jira Automation webhooks.
|
||||||
|
|
||||||
|
Triggers a new LangGraph run when a comment mentioning ``@openswe`` is
|
||||||
|
added to an issue. Unlike Linear, Jira Cloud has no native outgoing-webhook
|
||||||
|
signing, so this is fronted by a Jira **Automation** rule (trigger:
|
||||||
|
"Issue commented") with a "Send web request" action posting a custom JSON
|
||||||
|
body to this route, carrying the shared-secret token in
|
||||||
|
``X-Automation-Webhook-Token``.
|
||||||
|
|
||||||
|
Expected payload (the Automation rule's custom JSON body, built from smart
|
||||||
|
values)::
|
||||||
|
|
||||||
|
{
|
||||||
|
"issue_key": "PROJ-123",
|
||||||
|
"comment_id": "10050",
|
||||||
|
"comment_author_is_bot": false
|
||||||
|
}
|
||||||
|
|
||||||
|
``issue_key`` (validated against the Jira key format) and ``comment_id`` are
|
||||||
|
**required** — they are the only fields trusted from the unsigned body, and
|
||||||
|
only as a pointer. The triggering comment's real author and text are then
|
||||||
|
re-fetched from Jira server-side (``fetch_jira_comment``) and everything
|
||||||
|
security-relevant (identity/attribution, the ``@openswe`` trigger check, the
|
||||||
|
prompt text, repo routing) is derived from that authoritative record, never
|
||||||
|
from payload-supplied author/body fields. ``comment_author_is_bot`` is an
|
||||||
|
optional cheap early-out only. A comment that cannot be corroborated
|
||||||
|
server-side is rejected.
|
||||||
|
"""
|
||||||
|
common.logger.info("Received Jira webhook")
|
||||||
|
if not common.verify_jira_source_ip(request):
|
||||||
|
raise common.HTTPException(status_code=403, detail="Source IP not allowed")
|
||||||
|
|
||||||
|
if not common.verify_jira_secret(request.headers):
|
||||||
|
common.logger.warning("Invalid Jira webhook token")
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
if not common.verify_jira_signature(body, request.headers):
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = common.json.loads(body)
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
common.logger.exception("Failed to parse Jira webhook JSON")
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
|
||||||
|
# Cheap early-out on the (untrusted) payload before any Jira API call.
|
||||||
|
if payload.get("comment_author_is_bot"):
|
||||||
|
common.logger.debug("Ignoring webhook: comment is from a bot")
|
||||||
|
return {"status": "ignored", "reason": "Comment is from a bot"}
|
||||||
|
|
||||||
|
issue_key = payload.get("issue_key", "") or ""
|
||||||
|
if not common.is_valid_jira_issue_key(issue_key):
|
||||||
|
common.logger.debug("Ignoring webhook: missing or malformed issue key")
|
||||||
|
return {"status": "ignored", "reason": "Missing or malformed issue key"}
|
||||||
|
|
||||||
|
comment_id = payload.get("comment_id", "") or ""
|
||||||
|
if not comment_id:
|
||||||
|
common.logger.debug("Ignoring webhook: no comment id to corroborate")
|
||||||
|
return {"status": "ignored", "reason": "No comment id in payload"}
|
||||||
|
|
||||||
|
# Corroborate against the real Jira record. The webhook body is unsigned, so
|
||||||
|
# the triggering comment's author and text are read server-side (matched by
|
||||||
|
# comment_id) rather than trusted from the payload — this is what prevents a
|
||||||
|
# secret-holder from spoofing the author (to hijack another user's token) or
|
||||||
|
# injecting arbitrary agent instructions. A comment that can't be fetched
|
||||||
|
# (nonexistent issue/comment or a forged event) is rejected.
|
||||||
|
server_comment = await common.fetch_jira_comment(issue_key, comment_id)
|
||||||
|
if not server_comment:
|
||||||
|
common.logger.warning(
|
||||||
|
"Rejecting Jira webhook: comment %s on %s could not be corroborated",
|
||||||
|
comment_id,
|
||||||
|
issue_key,
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "Triggering comment not found"}
|
||||||
|
|
||||||
|
author = server_comment.get("author") or {}
|
||||||
|
account_id = author.get("account_id") or ""
|
||||||
|
display_name = author.get("name") or ""
|
||||||
|
comment_body = server_comment.get("body") or ""
|
||||||
|
|
||||||
|
for prefix in common._GITHUB_BOT_MESSAGE_PREFIXES:
|
||||||
|
if comment_body.startswith(prefix):
|
||||||
|
common.logger.debug("Ignoring webhook: comment is our own bot message")
|
||||||
|
return {"status": "ignored", "reason": "Comment is our own bot message"}
|
||||||
|
if "@openswe" not in comment_body.lower():
|
||||||
|
common.logger.debug("Ignoring webhook: comment doesn't mention @openswe")
|
||||||
|
return {"status": "ignored", "reason": "Comment doesn't mention @openswe"}
|
||||||
|
|
||||||
|
# Derive the project key from the (validated, corroborated) issue key rather
|
||||||
|
# than trusting the payload's project_key for repo routing.
|
||||||
|
project_key = issue_key.split("-", 1)[0]
|
||||||
|
actor_email = await common.get_jira_user_email(account_id) if account_id else None
|
||||||
|
|
||||||
|
repo_config = common.extract_repo_from_text(
|
||||||
|
comment_body, default_owner=common.DEFAULT_REPO_OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
if repo_config:
|
||||||
|
common.logger.debug(
|
||||||
|
"Using repo from comment body: %s/%s",
|
||||||
|
repo_config["owner"],
|
||||||
|
repo_config["name"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
profile_repo = await common.get_profile_default_repo(
|
||||||
|
await common.resolve_login_from_email_async(actor_email) if actor_email else None
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
common.logger.exception("Failed to apply dashboard default_repo for Jira user")
|
||||||
|
profile_repo = None
|
||||||
|
if profile_repo:
|
||||||
|
common.logger.info(
|
||||||
|
"Applying dashboard default_repo for Jira user %s: %s/%s",
|
||||||
|
account_id,
|
||||||
|
profile_repo["owner"],
|
||||||
|
profile_repo["name"],
|
||||||
|
)
|
||||||
|
repo_config = profile_repo
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
repo_config = common.get_repo_config_from_jira_mapping(project_key)
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
repo_config = await common.get_team_default_repo()
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
return {"status": "ignored", "reason": "No default repository configured"}
|
||||||
|
|
||||||
|
if not common._is_repo_allowed(repo_config):
|
||||||
|
common.logger.warning(
|
||||||
|
"Rejecting Jira webhook: repo '%s/%s' not in allowlist",
|
||||||
|
repo_config.get("owner"),
|
||||||
|
repo_config.get("name"),
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "Repository not in allowlist"}
|
||||||
|
|
||||||
|
issue_data = {
|
||||||
|
"key": issue_key,
|
||||||
|
"project_key": project_key,
|
||||||
|
"triggering_comment": comment_body,
|
||||||
|
"triggering_comment_id": comment_id,
|
||||||
|
"comment_author": {
|
||||||
|
"account_id": account_id,
|
||||||
|
"email": actor_email,
|
||||||
|
"name": display_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
common.logger.info(
|
||||||
|
"Accepted webhook for issue '%s', scheduling background task",
|
||||||
|
issue_key,
|
||||||
|
)
|
||||||
|
background_tasks.add_task(service.process_jira_issue, issue_data, repo_config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "accepted",
|
||||||
|
"message": f"Processing issue '{issue_key}' for repo "
|
||||||
|
f"{repo_config['owner']}/{repo_config['name']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/jira")
|
||||||
|
async def jira_webhook_verify() -> dict[str, str]:
|
||||||
|
"""Verify endpoint for Jira webhook setup."""
|
||||||
|
return {"status": "ok", "message": "Jira webhook endpoint is active"}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Linear webhook handler — moved out of webapp.py (behavior-identical).
|
"""Linear webhook handler — moved out of common.py (behavior-identical).
|
||||||
|
|
||||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
Helpers and constants stay in common.py; they are accessed through the module
|
||||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
object (``common.X``) so tests that monkeypatch them keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||||
import httpx
|
import httpx
|
||||||
from langchain_core.messages.content import create_text_block
|
from langchain_core.messages.content import create_text_block
|
||||||
|
|
||||||
from agent import webapp
|
from . import common
|
||||||
|
|
||||||
|
|
||||||
async def process_linear_issue( # noqa: PLR0912, PLR0915
|
async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
|
|
@ -23,7 +23,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
repo_config: The repo configuration with owner and name.
|
repo_config: The repo configuration with owner and name.
|
||||||
"""
|
"""
|
||||||
issue_id = issue_data.get("id", "")
|
issue_id = issue_data.get("id", "")
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Processing Linear issue %s for repo %s/%s",
|
"Processing Linear issue %s for repo %s/%s",
|
||||||
issue_id,
|
issue_id,
|
||||||
repo_config.get("owner"),
|
repo_config.get("owner"),
|
||||||
|
|
@ -32,11 +32,11 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
|
|
||||||
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
||||||
if triggering_comment_id:
|
if triggering_comment_id:
|
||||||
await webapp.react_to_linear_comment(triggering_comment_id, "👀")
|
await common.react_to_linear_comment(triggering_comment_id, "👀")
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_issue(issue_id)
|
thread_id = common.generate_thread_id_from_issue(issue_id)
|
||||||
|
|
||||||
full_issue = await webapp.fetch_linear_issue_details(issue_id)
|
full_issue = await common.fetch_linear_issue_details(issue_id)
|
||||||
if not full_issue:
|
if not full_issue:
|
||||||
full_issue = issue_data
|
full_issue = issue_data
|
||||||
|
|
||||||
|
|
@ -64,15 +64,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
user_email = assignee.get("email")
|
user_email = assignee.get("email")
|
||||||
user_name = user_name or assignee.get("name")
|
user_name = user_name or assignee.get("name")
|
||||||
|
|
||||||
webapp.logger.info("User email for issue %s: %s", issue_id, user_email)
|
common.logger.info("User email for issue %s: %s", issue_id, user_email)
|
||||||
|
|
||||||
title = full_issue.get("title", "No title")
|
title = full_issue.get("title", "No title")
|
||||||
description = full_issue.get("description") or "No description"
|
description = full_issue.get("description") or "No description"
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
description_image_urls = webapp.extract_image_urls(description)
|
description_image_urls = common.extract_image_urls(description)
|
||||||
if description_image_urls:
|
if description_image_urls:
|
||||||
image_urls.extend(description_image_urls)
|
image_urls.extend(description_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in issue description",
|
"Found %d image URL(s) in issue description",
|
||||||
len(description_image_urls),
|
len(description_image_urls),
|
||||||
)
|
)
|
||||||
|
|
@ -107,12 +107,12 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
trigger_index = comment_id_to_index.get(triggering_comment_id)
|
trigger_index = comment_id_to_index.get(triggering_comment_id)
|
||||||
if trigger_index is not None:
|
if trigger_index is not None:
|
||||||
relevant_comments = comments[trigger_index:]
|
relevant_comments = comments[trigger_index:]
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Using triggering comment index %d to build relevant comments",
|
"Using triggering comment index %d to build relevant comments",
|
||||||
trigger_index,
|
trigger_index,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
relevant_comments = webapp.get_recent_comments(comments, bot_message_prefixes)
|
relevant_comments = common.get_recent_comments(comments, bot_message_prefixes)
|
||||||
|
|
||||||
if relevant_comments:
|
if relevant_comments:
|
||||||
comments_text = "\n\n## Comments:\n"
|
comments_text = "\n\n## Comments:\n"
|
||||||
|
|
@ -120,10 +120,10 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
user = comment.get("user") or {}
|
user = comment.get("user") or {}
|
||||||
author = user.get("name", "User")
|
author = user.get("name", "User")
|
||||||
body = comment.get("body", "")
|
body = comment.get("body", "")
|
||||||
body_image_urls = webapp.extract_image_urls(body)
|
body_image_urls = common.extract_image_urls(body)
|
||||||
if body_image_urls:
|
if body_image_urls:
|
||||||
image_urls.extend(body_image_urls)
|
image_urls.extend(body_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in comment by %s",
|
"Found %d image URL(s) in comment by %s",
|
||||||
len(body_image_urls),
|
len(body_image_urls),
|
||||||
author,
|
author,
|
||||||
|
|
@ -137,16 +137,16 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
comments_text = "\n\n## Comments:\n"
|
comments_text = "\n\n## Comments:\n"
|
||||||
trigger_author = comment_author.get("name", "Unknown")
|
trigger_author = comment_author.get("name", "Unknown")
|
||||||
trigger_body = triggering_comment
|
trigger_body = triggering_comment
|
||||||
trigger_image_urls = webapp.extract_image_urls(trigger_body)
|
trigger_image_urls = common.extract_image_urls(trigger_body)
|
||||||
if trigger_image_urls:
|
if trigger_image_urls:
|
||||||
image_urls.extend(trigger_image_urls)
|
image_urls.extend(trigger_image_urls)
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Found %d image URL(s) in triggering comment by %s",
|
"Found %d image URL(s) in triggering comment by %s",
|
||||||
len(trigger_image_urls),
|
len(trigger_image_urls),
|
||||||
trigger_author,
|
trigger_author,
|
||||||
)
|
)
|
||||||
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
|
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Appended triggering comment %s not present in issue comments list",
|
"Appended triggering comment %s not present in issue comments list",
|
||||||
triggering_comment_id or "<missing-id>",
|
triggering_comment_id or "<missing-id>",
|
||||||
)
|
)
|
||||||
|
|
@ -183,15 +183,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
# user-mapping store Slack uses, so PRs open *as the triggering user* and the
|
# user-mapping store Slack uses, so PRs open *as the triggering user* and the
|
||||||
# thread is tagged for the dashboard. Restricted to the comment author so
|
# thread is tagged for the dashboard. Restricted to the comment author so
|
||||||
# token attribution never falls back to creator/assignee.
|
# token attribution never falls back to creator/assignee.
|
||||||
mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None
|
mapped_login = await common.resolve_login_from_email_async(actor_email) if actor_email else None
|
||||||
|
|
||||||
image_model_override: tuple[str, str] | None = None
|
image_model_override: tuple[str, str] | None = None
|
||||||
if image_urls:
|
if image_urls:
|
||||||
image_urls = webapp.dedupe_urls(image_urls)
|
image_urls = common.dedupe_urls(image_urls)
|
||||||
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
|
||||||
if not webapp.model_supports_images(resolved_model_id):
|
if not common.model_supports_images(resolved_model_id):
|
||||||
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
|
fallback_model_id, fallback_effort = common.default_vision_model_pair()
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Using vision fallback model %s for %d Linear image(s); configured model %s "
|
"Using vision fallback model %s for %d Linear image(s); configured model %s "
|
||||||
"does not support images",
|
"does not support images",
|
||||||
fallback_model_id,
|
fallback_model_id,
|
||||||
|
|
@ -200,15 +200,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
)
|
)
|
||||||
resolved_model_id = fallback_model_id
|
resolved_model_id = fallback_model_id
|
||||||
image_model_override = (fallback_model_id, fallback_effort)
|
image_model_override = (fallback_model_id, fallback_effort)
|
||||||
webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
|
common.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
|
||||||
webapp.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
|
common.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=common.DEFAULT_HTTP_TIMEOUT) as client:
|
||||||
for image_url in image_urls:
|
for image_url in image_urls:
|
||||||
image_block = await webapp.fetch_image_block(image_url, client)
|
image_block = await common.fetch_image_block(image_url, client)
|
||||||
if image_block:
|
if image_block:
|
||||||
content_blocks.append(image_block)
|
content_blocks.append(image_block)
|
||||||
webapp.logger.info("Built %d content block(s) for prompt", len(content_blocks))
|
common.logger.info("Built %d content block(s) for prompt", len(content_blocks))
|
||||||
|
|
||||||
linear_project_id = ""
|
linear_project_id = ""
|
||||||
linear_issue_number = ""
|
linear_issue_number = ""
|
||||||
|
|
@ -237,7 +237,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
configurable["agent_model_id"] = image_model_override[0]
|
configurable["agent_model_id"] = image_model_override[0]
|
||||||
configurable["agent_effort"] = image_model_override[1]
|
configurable["agent_effort"] = image_model_override[1]
|
||||||
|
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="linear",
|
source="linear",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -247,16 +247,16 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
source_context={"linear_issue": configurable["linear_issue"]},
|
source_context={"linear_issue": configurable["linear_issue"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
content_blocks,
|
content_blocks,
|
||||||
configurable,
|
configurable,
|
||||||
source="linear",
|
source="linear",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"LangGraph run dispatched for thread %s (run=%s)",
|
"LangGraph run dispatched for thread %s (run=%s)",
|
||||||
thread_id,
|
thread_id,
|
||||||
run.get("run_id") if isinstance(run, dict) else None,
|
run.get("run_id") if isinstance(run, dict) else None,
|
||||||
)
|
)
|
||||||
await webapp.post_linear_trace_comment(issue_id, thread_id, triggering_comment_id)
|
await common.post_linear_trace_comment(issue_id, thread_id, triggering_comment_id)
|
||||||
|
|
|
||||||
168
agent/webhooks/linear_routes.py
Normal file
168
agent/webhooks/linear_routes.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Linear webhook HTTP routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import linear as service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/linear")
|
||||||
|
async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Handle Linear webhooks.
|
||||||
|
|
||||||
|
Triggers a new LangGraph run when an issue gets the 'open-swe' label added.
|
||||||
|
"""
|
||||||
|
common.logger.info("Received Linear webhook")
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
signature = request.headers.get("Linear-Signature", "")
|
||||||
|
if not common.verify_linear_signature(body, signature, common.LINEAR_WEBHOOK_SECRET):
|
||||||
|
common.logger.warning("Invalid webhook signature")
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = common.json.loads(body)
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
common.logger.exception("Failed to parse webhook JSON")
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
|
||||||
|
if payload.get("type") != "Comment":
|
||||||
|
common.logger.debug("Ignoring webhook: not a Comment event")
|
||||||
|
return {"status": "ignored", "reason": "Not a Comment event"}
|
||||||
|
|
||||||
|
action = payload.get("action")
|
||||||
|
if action != "create":
|
||||||
|
common.logger.debug("Ignoring webhook: action is %s, not create", action)
|
||||||
|
return {
|
||||||
|
"status": "ignored",
|
||||||
|
"reason": f"Comment action is '{action}', only processing 'create'",
|
||||||
|
}
|
||||||
|
|
||||||
|
data = payload.get("data", {})
|
||||||
|
|
||||||
|
if data.get("botActor"):
|
||||||
|
common.logger.debug("Ignoring webhook: comment is from a bot")
|
||||||
|
return {"status": "ignored", "reason": "Comment is from a bot"}
|
||||||
|
|
||||||
|
comment_body = data.get("body", "")
|
||||||
|
bot_message_prefixes = [
|
||||||
|
"🔐 **GitHub Authentication Required**",
|
||||||
|
"✅ **Pull Request Created**",
|
||||||
|
"✅ **Pull Request Updated**",
|
||||||
|
"**Pull Request Created**",
|
||||||
|
"**Pull Request Updated**",
|
||||||
|
"🤖 **Agent Response**",
|
||||||
|
"❌ **Agent Error**",
|
||||||
|
]
|
||||||
|
for prefix in bot_message_prefixes:
|
||||||
|
if comment_body.startswith(prefix):
|
||||||
|
common.logger.debug("Ignoring webhook: comment is our own bot message")
|
||||||
|
return {"status": "ignored", "reason": "Comment is our own bot message"}
|
||||||
|
if "@openswe" not in comment_body.lower():
|
||||||
|
common.logger.debug("Ignoring webhook: comment doesn't mention @openswe")
|
||||||
|
return {"status": "ignored", "reason": "Comment doesn't mention @openswe"}
|
||||||
|
|
||||||
|
issue = data.get("issue", {})
|
||||||
|
if not issue:
|
||||||
|
common.logger.debug("Ignoring webhook: no issue data in comment")
|
||||||
|
return {"status": "ignored", "reason": "No issue data in comment"}
|
||||||
|
|
||||||
|
# Fetch full issue details to get project info (webhook doesn't include it)
|
||||||
|
issue_id = issue.get("id", "")
|
||||||
|
full_issue = await common.fetch_linear_issue_details(issue_id)
|
||||||
|
if not full_issue:
|
||||||
|
common.logger.warning("Failed to fetch full issue details, using webhook data")
|
||||||
|
full_issue = issue
|
||||||
|
|
||||||
|
repo_config = common.extract_repo_from_text(
|
||||||
|
comment_body, default_owner=common.DEFAULT_REPO_OWNER
|
||||||
|
)
|
||||||
|
|
||||||
|
if repo_config:
|
||||||
|
common.logger.debug(
|
||||||
|
"Using repo from comment body: %s/%s",
|
||||||
|
repo_config["owner"],
|
||||||
|
repo_config["name"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
comment_user_email = (data.get("user") or {}).get("email")
|
||||||
|
try:
|
||||||
|
profile_repo = await common.get_profile_default_repo(
|
||||||
|
await common.resolve_login_from_email_async(comment_user_email)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
common.logger.exception("Failed to apply dashboard default_repo for Linear user")
|
||||||
|
profile_repo = None
|
||||||
|
if profile_repo:
|
||||||
|
common.logger.info(
|
||||||
|
"Applying dashboard default_repo for Linear user %s: %s/%s",
|
||||||
|
comment_user_email,
|
||||||
|
profile_repo["owner"],
|
||||||
|
profile_repo["name"],
|
||||||
|
)
|
||||||
|
repo_config = profile_repo
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
team = full_issue.get("team", {})
|
||||||
|
team_name = team.get("name", "") if team else ""
|
||||||
|
project = full_issue.get("project")
|
||||||
|
project_name = project.get("name", "") if project else ""
|
||||||
|
|
||||||
|
team_identifier = team_name.strip() if team_name else ""
|
||||||
|
project_key = project_name.strip() if project_name else ""
|
||||||
|
|
||||||
|
repo_config = common.get_repo_config_from_team_mapping(team_identifier, project_key)
|
||||||
|
|
||||||
|
common.logger.debug(
|
||||||
|
"Team/project lookup result",
|
||||||
|
extra={
|
||||||
|
"team_name": team_identifier,
|
||||||
|
"project_name": project_key,
|
||||||
|
"repo_config": repo_config,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
repo_config = await common.get_team_default_repo()
|
||||||
|
|
||||||
|
if not repo_config:
|
||||||
|
return {"status": "ignored", "reason": "No default repository configured"}
|
||||||
|
|
||||||
|
if not common._is_repo_allowed(repo_config):
|
||||||
|
common.logger.warning(
|
||||||
|
"Rejecting Linear webhook: repo '%s/%s' not in allowlist",
|
||||||
|
repo_config.get("owner"),
|
||||||
|
repo_config.get("name"),
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "Repository not in allowlist"}
|
||||||
|
|
||||||
|
repo_owner = repo_config["owner"]
|
||||||
|
repo_name = repo_config["name"]
|
||||||
|
|
||||||
|
issue["triggering_comment"] = comment_body
|
||||||
|
issue["triggering_comment_id"] = data.get("id", "")
|
||||||
|
comment_user = data.get("user", {})
|
||||||
|
if comment_user:
|
||||||
|
issue["comment_author"] = comment_user
|
||||||
|
|
||||||
|
common.logger.info(
|
||||||
|
"Accepted webhook for issue '%s' (%s), scheduling background task",
|
||||||
|
issue.get("title"),
|
||||||
|
issue.get("id"),
|
||||||
|
)
|
||||||
|
background_tasks.add_task(service.process_linear_issue, issue, repo_config)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "accepted",
|
||||||
|
"message": f"Processing issue '{issue.get('title')}' for repo {repo_owner}/{repo_name}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/linear")
|
||||||
|
async def linear_webhook_verify() -> dict[str, str]:
|
||||||
|
"""Verify endpoint for Linear webhook setup."""
|
||||||
|
return {"status": "ok", "message": "Linear webhook endpoint is active"}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Slack webhook handler — moved out of webapp.py (behavior-identical).
|
"""Slack webhook handler — moved out of common.py (behavior-identical).
|
||||||
|
|
||||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
Helpers and constants stay in common.py; they are accessed through the module
|
||||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
object (``common.X``) so tests that monkeypatch them keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
from langchain_core.messages.content import create_text_block
|
from langchain_core.messages.content import create_text_block
|
||||||
|
|
||||||
from agent import webapp
|
from . import common
|
||||||
|
|
||||||
|
|
||||||
def _format_slack_thread_section(
|
def _format_slack_thread_section(
|
||||||
|
|
@ -31,9 +31,9 @@ def _format_slack_thread_section(
|
||||||
lines.append(f"- Channel name: #{channel_name}")
|
lines.append(f"- Channel name: #{channel_name}")
|
||||||
lines.append(f"- Thread TS: {thread_ts}")
|
lines.append(f"- Thread TS: {thread_ts}")
|
||||||
lines.append(f"- Context starts at: {context_source}")
|
lines.append(f"- Context starts at: {context_source}")
|
||||||
channel_description = webapp.get_slack_channel_context_description(channel_context)
|
channel_description = common.get_slack_channel_context_description(channel_context)
|
||||||
if channel_description:
|
if channel_description:
|
||||||
lines.extend(webapp.format_untrusted_channel_description(channel_description))
|
lines.extend(common.format_untrusted_channel_description(channel_description))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
|
||||||
try:
|
try:
|
||||||
await _process_slack_mention_impl(event_data, repo_config)
|
await _process_slack_mention_impl(event_data, repo_config)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.exception("Unexpected error while processing Slack mention")
|
common.logger.exception("Unexpected error while processing Slack mention")
|
||||||
await _notify_slack_processing_error(event_data, repo_config)
|
await _notify_slack_processing_error(event_data, repo_config)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,13 +58,13 @@ async def _notify_slack_processing_error(
|
||||||
if not channel_id or not thread_ts:
|
if not channel_id or not thread_ts:
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||||
try:
|
try:
|
||||||
clean_text = (
|
clean_text = (
|
||||||
webapp.strip_bot_mention(text, bot_user_id, bot_username=webapp.SLACK_BOT_USERNAME)
|
common.strip_bot_mention(text, bot_user_id, bot_username=common.SLACK_BOT_USERNAME)
|
||||||
or "Slack request"
|
or "Slack request"
|
||||||
)
|
)
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="slack",
|
source="slack",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -79,12 +79,12 @@ async def _notify_slack_processing_error(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Could not persist Slack error metadata for thread %s", thread_id, exc_info=True
|
"Could not persist Slack error metadata for thread %s", thread_id, exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await webapp.get_client(url=webapp.LANGGRAPH_URL).threads.update(
|
await common.get_client(url=common.LANGGRAPH_URL).threads.update(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
metadata={
|
metadata={
|
||||||
"latest_run_status": "error",
|
"latest_run_status": "error",
|
||||||
|
|
@ -92,14 +92,14 @@ async def _notify_slack_processing_error(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.warning("Could not mark Slack thread %s as errored", thread_id, exc_info=True)
|
common.logger.warning("Could not mark Slack thread %s as errored", thread_id, exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await webapp.set_slack_assistant_status(channel_id, thread_ts, status="")
|
await common.set_slack_assistant_status(channel_id, thread_ts, status="")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug("Could not clear Slack assistant status", exc_info=True)
|
common.logger.debug("Could not clear Slack assistant status", exc_info=True)
|
||||||
|
|
||||||
dashboard_url = webapp.dashboard_thread_url(thread_id)
|
dashboard_url = common.dashboard_thread_url(thread_id)
|
||||||
message = (
|
message = (
|
||||||
"⚠️ I hit an unexpected error while handling this Slack thread. "
|
"⚠️ I hit an unexpected error while handling this Slack thread. "
|
||||||
"Send another message and I'll try again."
|
"Send another message and I'll try again."
|
||||||
|
|
@ -107,9 +107,9 @@ async def _notify_slack_processing_error(
|
||||||
if dashboard_url:
|
if dashboard_url:
|
||||||
message += f" You can view the error in <{dashboard_url}|Open SWE Web>."
|
message += f" You can view the error in <{dashboard_url}|Open SWE Web>."
|
||||||
try:
|
try:
|
||||||
await webapp.post_slack_thread_reply(channel_id, thread_ts, message)
|
await common.post_slack_thread_reply(channel_id, thread_ts, message)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Could not post Slack error notification for thread %s", thread_id, exc_info=True
|
"Could not post Slack error notification for thread %s", thread_id, exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -127,11 +127,11 @@ async def _process_slack_mention_impl(
|
||||||
channel_context = (
|
channel_context = (
|
||||||
channel_context_raw
|
channel_context_raw
|
||||||
if isinstance(channel_context_raw, dict)
|
if isinstance(channel_context_raw, dict)
|
||||||
else webapp.normalize_slack_channel_context(channel_id, None)
|
else common.normalize_slack_channel_context(channel_id, None)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not channel_id or not thread_ts or not event_ts:
|
if not channel_id or not thread_ts or not event_ts:
|
||||||
webapp.logger.warning(
|
common.logger.warning(
|
||||||
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
|
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
|
||||||
channel_id,
|
channel_id,
|
||||||
thread_ts,
|
thread_ts,
|
||||||
|
|
@ -139,20 +139,20 @@ async def _process_slack_mention_impl(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
await common.set_slack_assistant_status(channel_id, thread_ts)
|
||||||
|
|
||||||
thread_id = webapp.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||||
|
|
||||||
# Prime the user-mapping cache so login/email/slack-id lookups below are warm.
|
# Prime the user-mapping cache so login/email/slack-id lookups below are warm.
|
||||||
try:
|
try:
|
||||||
await webapp.refresh_user_mapping_cache()
|
await common.refresh_user_mapping_cache()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug("Could not refresh user mapping cache for Slack mention", exc_info=True)
|
common.logger.debug("Could not refresh user mapping cache for Slack mention", exc_info=True)
|
||||||
|
|
||||||
user_email = None
|
user_email = None
|
||||||
user_name = ""
|
user_name = ""
|
||||||
if user_id:
|
if user_id:
|
||||||
slack_user = await webapp.get_slack_user_info(user_id)
|
slack_user = await common.get_slack_user_info(user_id)
|
||||||
if slack_user:
|
if slack_user:
|
||||||
profile = slack_user.get("profile", {})
|
profile = slack_user.get("profile", {})
|
||||||
if isinstance(profile, dict):
|
if isinstance(profile, dict):
|
||||||
|
|
@ -165,26 +165,26 @@ async def _process_slack_mention_impl(
|
||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_messages = await webapp.fetch_slack_thread_messages(channel_id, thread_ts)
|
thread_messages = await common.fetch_slack_thread_messages(channel_id, thread_ts)
|
||||||
if not any(str(message.get("ts")) == str(event_ts) for message in thread_messages):
|
if not any(str(message.get("ts")) == str(event_ts) for message in thread_messages):
|
||||||
thread_messages.append({"ts": event_ts, "text": text, "user": user_id})
|
thread_messages.append({"ts": event_ts, "text": text, "user": user_id})
|
||||||
|
|
||||||
context_messages, context_mode = webapp.select_slack_context_messages(
|
context_messages, context_mode = common.select_slack_context_messages(
|
||||||
thread_messages, event_ts, bot_user_id, webapp.SLACK_BOT_USERNAME
|
thread_messages, event_ts, bot_user_id, common.SLACK_BOT_USERNAME
|
||||||
)
|
)
|
||||||
context_user_ids = [
|
context_user_ids = [
|
||||||
value
|
value
|
||||||
for value in (message.get("user") for message in context_messages)
|
for value in (message.get("user") for message in context_messages)
|
||||||
if isinstance(value, str) and value
|
if isinstance(value, str) and value
|
||||||
]
|
]
|
||||||
user_names_by_id = await webapp.get_slack_user_names(context_user_ids)
|
user_names_by_id = await common.get_slack_user_names(context_user_ids)
|
||||||
if user_id and user_name and user_id not in user_names_by_id:
|
if user_id and user_name and user_id not in user_names_by_id:
|
||||||
user_names_by_id[user_id] = user_name
|
user_names_by_id[user_id] = user_name
|
||||||
context_text = webapp.format_slack_messages_for_prompt(
|
context_text = common.format_slack_messages_for_prompt(
|
||||||
context_messages,
|
context_messages,
|
||||||
user_names_by_id,
|
user_names_by_id,
|
||||||
bot_user_id=bot_user_id,
|
bot_user_id=bot_user_id,
|
||||||
bot_username=webapp.SLACK_BOT_USERNAME,
|
bot_username=common.SLACK_BOT_USERNAME,
|
||||||
)
|
)
|
||||||
context_source = (
|
context_source = (
|
||||||
"the previous message where I was tagged"
|
"the previous message where I was tagged"
|
||||||
|
|
@ -192,13 +192,13 @@ async def _process_slack_mention_impl(
|
||||||
else "the beginning of the thread"
|
else "the beginning of the thread"
|
||||||
)
|
)
|
||||||
clean_text = (
|
clean_text = (
|
||||||
webapp.strip_bot_mention(text, bot_user_id, bot_username=webapp.SLACK_BOT_USERNAME)
|
common.strip_bot_mention(text, bot_user_id, bot_username=common.SLACK_BOT_USERNAME)
|
||||||
or "(no text in mention)"
|
or "(no text in mention)"
|
||||||
)
|
)
|
||||||
trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user")
|
trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user")
|
||||||
|
|
||||||
# Auto-resolve cross-posted Slack message links in context
|
# Auto-resolve cross-posted Slack message links in context
|
||||||
resolved_links_section, image_urls_from_links = await webapp.resolve_slack_links_in_context(
|
resolved_links_section, image_urls_from_links = await common.resolve_slack_links_in_context(
|
||||||
context_messages, user_names_by_id
|
context_messages, user_names_by_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -223,8 +223,8 @@ async def _process_slack_mention_impl(
|
||||||
)
|
)
|
||||||
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
|
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
|
||||||
|
|
||||||
image_urls = webapp.dedupe_urls(
|
image_urls = common.dedupe_urls(
|
||||||
[url for msg in context_messages for url in webapp.extract_image_urls(msg.get("text", ""))]
|
[url for msg in context_messages for url in common.extract_image_urls(msg.get("text", ""))]
|
||||||
+ [
|
+ [
|
||||||
f["url_private"]
|
f["url_private"]
|
||||||
for msg in context_messages
|
for msg in context_messages
|
||||||
|
|
@ -236,16 +236,16 @@ async def _process_slack_mention_impl(
|
||||||
+ image_urls_from_links
|
+ image_urls_from_links
|
||||||
)
|
)
|
||||||
|
|
||||||
mapped_login = await webapp.login_for_slack_id(user_id)
|
mapped_login = await common.login_for_slack_id(user_id)
|
||||||
if not mapped_login and user_email:
|
if not mapped_login and user_email:
|
||||||
mapped_login = await webapp.login_for_email(user_email)
|
mapped_login = await common.login_for_email(user_email)
|
||||||
|
|
||||||
image_model_override: tuple[str, str] | None = None
|
image_model_override: tuple[str, str] | None = None
|
||||||
if image_urls:
|
if image_urls:
|
||||||
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
|
||||||
if not webapp.model_supports_images(resolved_model_id):
|
if not common.model_supports_images(resolved_model_id):
|
||||||
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
|
fallback_model_id, fallback_effort = common.default_vision_model_pair()
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Using vision fallback model %s for %d Slack image(s); configured model %s "
|
"Using vision fallback model %s for %d Slack image(s); configured model %s "
|
||||||
"does not support images",
|
"does not support images",
|
||||||
fallback_model_id,
|
fallback_model_id,
|
||||||
|
|
@ -254,10 +254,10 @@ async def _process_slack_mention_impl(
|
||||||
)
|
)
|
||||||
resolved_model_id = fallback_model_id
|
resolved_model_id = fallback_model_id
|
||||||
image_model_override = (fallback_model_id, fallback_effort)
|
image_model_override = (fallback_model_id, fallback_effort)
|
||||||
webapp.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
|
common.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
|
||||||
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as http_client:
|
async with httpx.AsyncClient(timeout=common.DEFAULT_HTTP_TIMEOUT) as http_client:
|
||||||
for image_url in image_urls:
|
for image_url in image_urls:
|
||||||
image_block = await webapp.fetch_image_block(image_url, http_client)
|
image_block = await common.fetch_image_block(image_url, http_client)
|
||||||
if image_block:
|
if image_block:
|
||||||
content_blocks.append(image_block)
|
content_blocks.append(image_block)
|
||||||
|
|
||||||
|
|
@ -269,9 +269,9 @@ async def _process_slack_mention_impl(
|
||||||
user_token: str | None = None
|
user_token: str | None = None
|
||||||
if mapped_login:
|
if mapped_login:
|
||||||
try:
|
try:
|
||||||
user_token = await webapp.get_valid_access_token(mapped_login)
|
user_token = await common.get_valid_access_token(mapped_login)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Failed to resolve GitHub token for %s; treating as unauthenticated",
|
"Failed to resolve GitHub token for %s; treating as unauthenticated",
|
||||||
mapped_login,
|
mapped_login,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|
@ -279,7 +279,7 @@ async def _process_slack_mention_impl(
|
||||||
user_token = None
|
user_token = None
|
||||||
has_valid_user_token = bool(user_token)
|
has_valid_user_token = bool(user_token)
|
||||||
|
|
||||||
if not has_valid_user_token and not webapp.is_bot_token_only_mode():
|
if not has_valid_user_token and not common.is_bot_token_only_mode():
|
||||||
# A stored-but-unusable token means "sign in again"; no record at all
|
# A stored-but-unusable token means "sign in again"; no record at all
|
||||||
# means the user has never connected GitHub + Slack via the dashboard.
|
# means the user has never connected GitHub + Slack via the dashboard.
|
||||||
# Guard the store read like token resolution above so a transient
|
# Guard the store read like token resolution above so a transient
|
||||||
|
|
@ -287,24 +287,24 @@ async def _process_slack_mention_impl(
|
||||||
has_token_record = False
|
has_token_record = False
|
||||||
if mapped_login:
|
if mapped_login:
|
||||||
try:
|
try:
|
||||||
has_token_record = await webapp.has_access_token_record(mapped_login)
|
has_token_record = await common.has_access_token_record(mapped_login)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
webapp.logger.debug(
|
common.logger.debug(
|
||||||
"Failed to check GitHub token record for %s; prompting sign-in",
|
"Failed to check GitHub token record for %s; prompting sign-in",
|
||||||
mapped_login,
|
mapped_login,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
reason = "revoked" if has_token_record else "unlinked"
|
reason = "revoked" if has_token_record else "unlinked"
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Blocking Slack run for thread %s: no valid user GitHub token (%s)",
|
"Blocking Slack run for thread %s: no valid user GitHub token (%s)",
|
||||||
thread_id,
|
thread_id,
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
if user_id:
|
if user_id:
|
||||||
await webapp._post_account_link_prompt(
|
await common._post_account_link_prompt(
|
||||||
channel_id, thread_ts, user_id, user_email, reason=reason
|
channel_id, thread_ts, user_id, user_email, reason=reason
|
||||||
)
|
)
|
||||||
await webapp.set_slack_assistant_status(channel_id, thread_ts, status="")
|
await common.set_slack_assistant_status(channel_id, thread_ts, status="")
|
||||||
return
|
return
|
||||||
|
|
||||||
configurable: dict[str, Any] = {
|
configurable: dict[str, Any] = {
|
||||||
|
|
@ -327,17 +327,17 @@ async def _process_slack_mention_impl(
|
||||||
configurable["agent_model_id"] = image_model_override[0]
|
configurable["agent_model_id"] = image_model_override[0]
|
||||||
configurable["agent_effort"] = image_model_override[1]
|
configurable["agent_effort"] = image_model_override[1]
|
||||||
|
|
||||||
thread_plan_mode = await webapp._get_thread_plan_mode(thread_id)
|
thread_plan_mode = await common._get_thread_plan_mode(thread_id)
|
||||||
if thread_plan_mode is not None:
|
if thread_plan_mode is not None:
|
||||||
configurable["plan_mode"] = thread_plan_mode
|
configurable["plan_mode"] = thread_plan_mode
|
||||||
|
|
||||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
|
||||||
is_first_mention = not await webapp._thread_exists(thread_id)
|
is_first_mention = not await common._thread_exists(thread_id)
|
||||||
await webapp._upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
await common._upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
||||||
# Pass the login resolved above (from the stable Slack user id) so the thread is
|
# Pass the login resolved above (from the stable Slack user id) so the thread is
|
||||||
# always tagged with github_login — the key the dashboard searches by. Without
|
# always tagged with github_login — the key the dashboard searches by. Without
|
||||||
# it, upsert re-resolves from the Slack profile email, which can miss.
|
# it, upsert re-resolves from the Slack profile email, which can miss.
|
||||||
await webapp.upsert_agent_thread_owner_metadata(
|
await common.upsert_agent_thread_owner_metadata(
|
||||||
thread_id,
|
thread_id,
|
||||||
source="slack",
|
source="slack",
|
||||||
repo_config=repo_config,
|
repo_config=repo_config,
|
||||||
|
|
@ -347,25 +347,25 @@ async def _process_slack_mention_impl(
|
||||||
source_context={"slack_thread": configurable["slack_thread"]},
|
source_context={"slack_thread": configurable["slack_thread"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
run = await webapp.dispatch_agent_run(
|
run = await common.dispatch_agent_run(
|
||||||
thread_id,
|
thread_id,
|
||||||
content_blocks,
|
content_blocks,
|
||||||
configurable,
|
configurable,
|
||||||
source="slack",
|
source="slack",
|
||||||
metadata=webapp._AGENT_VERSION_METADATA,
|
metadata=common._AGENT_VERSION_METADATA,
|
||||||
client=langgraph_client,
|
client=langgraph_client,
|
||||||
)
|
)
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Slack LangGraph run %s dispatched for thread %s",
|
"Slack LangGraph run %s dispatched for thread %s",
|
||||||
webapp._run_id_for_logging(run),
|
common._run_id_for_logging(run),
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
run_id = run.get("run_id")
|
run_id = run.get("run_id")
|
||||||
if is_first_mention:
|
if is_first_mention:
|
||||||
trace_message_ts = await webapp.post_slack_trace_reply(channel_id, thread_ts, thread_id)
|
trace_message_ts = await common.post_slack_trace_reply(channel_id, thread_ts, thread_id)
|
||||||
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
await common.set_slack_assistant_status(channel_id, thread_ts)
|
||||||
if isinstance(run_id, str) and run_id:
|
if isinstance(run_id, str) and run_id:
|
||||||
await webapp.store_slack_run_mapping(
|
await common.store_slack_run_mapping(
|
||||||
langgraph_client,
|
langgraph_client,
|
||||||
channel_id,
|
channel_id,
|
||||||
thread_ts,
|
thread_ts,
|
||||||
|
|
@ -375,12 +375,12 @@ async def _process_slack_mention_impl(
|
||||||
triggering_user_id=user_id,
|
triggering_user_id=user_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
webapp.logger.info(
|
common.logger.info(
|
||||||
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
|
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
if isinstance(run_id, str) and run_id:
|
if isinstance(run_id, str) and run_id:
|
||||||
await webapp.store_slack_run_mapping(
|
await common.store_slack_run_mapping(
|
||||||
langgraph_client,
|
langgraph_client,
|
||||||
channel_id,
|
channel_id,
|
||||||
thread_ts,
|
thread_ts,
|
||||||
|
|
|
||||||
347
agent/webhooks/slack_routes.py
Normal file
347
agent/webhooks/slack_routes.py
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
"""Slack webhook HTTP routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import common
|
||||||
|
from . import slack as service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/slack")
|
||||||
|
async def slack_webhook(
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Handle Slack Event API webhooks for app mentions."""
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
signature = request.headers.get("X-Slack-Signature", "")
|
||||||
|
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||||
|
if not common.verify_slack_signature(
|
||||||
|
body=body,
|
||||||
|
timestamp=timestamp,
|
||||||
|
signature=signature,
|
||||||
|
secret=common.SLACK_SIGNING_SECRET,
|
||||||
|
):
|
||||||
|
common.logger.warning("Invalid Slack signature")
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = common.json.loads(body)
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
common.logger.exception("Failed to parse Slack webhook JSON")
|
||||||
|
return {"status": "error", "message": "Invalid JSON"}
|
||||||
|
|
||||||
|
if payload.get("type") == "url_verification":
|
||||||
|
challenge = payload.get("challenge", "")
|
||||||
|
return {"challenge": challenge}
|
||||||
|
|
||||||
|
if payload.get("type") != "event_callback":
|
||||||
|
return {"status": "ignored", "reason": "Not an event callback"}
|
||||||
|
|
||||||
|
event = payload.get("event", {})
|
||||||
|
|
||||||
|
if event.get("type") == "reaction_added":
|
||||||
|
reaction = event.get("reaction")
|
||||||
|
if reaction in common.FEEDBACK_REACTIONS:
|
||||||
|
background_tasks.add_task(
|
||||||
|
common.process_slack_reaction_added, event, payload.get("event_id", "")
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Reaction feedback queued"}
|
||||||
|
return {"status": "ignored", "reason": "Reaction not tracked for feedback"}
|
||||||
|
|
||||||
|
if event.get("type") == "reaction_removed":
|
||||||
|
reaction = event.get("reaction")
|
||||||
|
if reaction in common.FEEDBACK_REACTIONS:
|
||||||
|
background_tasks.add_task(
|
||||||
|
common.process_slack_reaction_removed, event, payload.get("event_id", "")
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Reaction removal queued"}
|
||||||
|
return {"status": "ignored", "reason": "Reaction not tracked for feedback"}
|
||||||
|
|
||||||
|
if event.get("type") != "app_mention":
|
||||||
|
message_text = event.get("text", "")
|
||||||
|
has_username_mention = bool(
|
||||||
|
event.get("type") == "message"
|
||||||
|
and common.SLACK_BOT_USERNAME
|
||||||
|
and f"@{common.SLACK_BOT_USERNAME}" in message_text
|
||||||
|
)
|
||||||
|
has_id_mention = bool(
|
||||||
|
event.get("type") == "message"
|
||||||
|
and common.SLACK_BOT_USER_ID
|
||||||
|
and f"<@{common.SLACK_BOT_USER_ID}>" in message_text
|
||||||
|
)
|
||||||
|
if not (has_username_mention or has_id_mention):
|
||||||
|
return {"status": "ignored", "reason": "Not an app_mention event"}
|
||||||
|
|
||||||
|
if event.get("subtype") == "bot_message" or event.get("bot_id"):
|
||||||
|
return {"status": "ignored", "reason": "Event from a bot"}
|
||||||
|
|
||||||
|
channel_id = event.get("channel", "")
|
||||||
|
event_ts = event.get("ts", "")
|
||||||
|
thread_ts = event.get("thread_ts") or event_ts
|
||||||
|
user_id = event.get("user", "")
|
||||||
|
text = event.get("text", "")
|
||||||
|
if not channel_id or not event_ts or not thread_ts:
|
||||||
|
return {"status": "ignored", "reason": "Missing channel/thread timestamp"}
|
||||||
|
|
||||||
|
bot_user_id = common.SLACK_BOT_USER_ID
|
||||||
|
if not bot_user_id:
|
||||||
|
authorizations = payload.get("authorizations", [])
|
||||||
|
if isinstance(authorizations, list) and authorizations:
|
||||||
|
auth_user_id = authorizations[0].get("user_id")
|
||||||
|
if isinstance(auth_user_id, str):
|
||||||
|
bot_user_id = auth_user_id
|
||||||
|
if not bot_user_id:
|
||||||
|
authed_users = payload.get("authed_users", [])
|
||||||
|
if isinstance(authed_users, list) and authed_users:
|
||||||
|
first_user = authed_users[0]
|
||||||
|
if isinstance(first_user, str):
|
||||||
|
bot_user_id = first_user
|
||||||
|
|
||||||
|
if bot_user_id and user_id == bot_user_id:
|
||||||
|
return {"status": "ignored", "reason": "Event from this bot user"}
|
||||||
|
|
||||||
|
channel_context = await common._get_slack_channel_context(channel_id)
|
||||||
|
|
||||||
|
if await common._is_docs_plz_slack_channel(channel_id, channel_context):
|
||||||
|
background_tasks.add_task(
|
||||||
|
common.post_slack_thread_reply,
|
||||||
|
channel_id,
|
||||||
|
thread_ts,
|
||||||
|
common.DOCS_PLZ_SLACK_GATE_REPLY,
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Slack mention gated for docs-plz"}
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"channel_context": channel_context,
|
||||||
|
"thread_ts": thread_ts,
|
||||||
|
"event_ts": event_ts,
|
||||||
|
"user_id": user_id,
|
||||||
|
"text": text,
|
||||||
|
"bot_user_id": bot_user_id,
|
||||||
|
}
|
||||||
|
repo_config = await common.get_slack_repo_config(
|
||||||
|
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
|
||||||
|
)
|
||||||
|
|
||||||
|
background_tasks.add_task(service.process_slack_mention, event_data, repo_config)
|
||||||
|
|
||||||
|
return {"status": "accepted", "message": "Slack mention queued"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/slack/interactivity")
|
||||||
|
async def slack_interactivity(
|
||||||
|
request: common.Request, background_tasks: common.BackgroundTasks
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Handle Slack Block Kit interactions."""
|
||||||
|
body = await request.body()
|
||||||
|
signature = request.headers.get("X-Slack-Signature", "")
|
||||||
|
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||||
|
if not common.verify_slack_signature(
|
||||||
|
body=body,
|
||||||
|
timestamp=timestamp,
|
||||||
|
signature=signature,
|
||||||
|
secret=common.SLACK_SIGNING_SECRET,
|
||||||
|
):
|
||||||
|
common.logger.warning("Invalid Slack interactivity signature")
|
||||||
|
raise common.HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
form = common.parse_qs(body.decode("utf-8"))
|
||||||
|
payload_raw = (form.get("payload") or [""])[0]
|
||||||
|
try:
|
||||||
|
payload = common.json.loads(payload_raw)
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
common.logger.exception("Failed to parse Slack interactivity payload")
|
||||||
|
return {"status": "error", "message": "Invalid payload"}
|
||||||
|
|
||||||
|
action = _first_open_swe_option_action(payload.get("actions"))
|
||||||
|
if action is None:
|
||||||
|
return {"status": "ignored", "reason": "No Open SWE action"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
action_value = common.json.loads(str(action.get("value") or "{}"))
|
||||||
|
except common.json.JSONDecodeError:
|
||||||
|
return {"status": "ignored", "reason": "Invalid action value"}
|
||||||
|
if action_value.get("type") == "workflow_push_approval":
|
||||||
|
workflow_action = str(action_value.get("action") or "").strip()
|
||||||
|
fingerprint = str(action_value.get("fingerprint") or "").strip()
|
||||||
|
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
|
||||||
|
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
|
||||||
|
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
|
||||||
|
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||||
|
channel_id = str(channel.get("id") or container.get("channel_id") or "")
|
||||||
|
thread_ts = str(
|
||||||
|
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
|
||||||
|
)
|
||||||
|
user_id = str(user.get("id") or "")
|
||||||
|
if not channel_id or not thread_ts or not fingerprint:
|
||||||
|
return {"status": "ignored", "reason": "Missing workflow approval context"}
|
||||||
|
|
||||||
|
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||||
|
if not await common._slack_user_is_thread_owner(thread_id, user_id):
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text="Only the person who requested this run can approve workflow file pushes.",
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "approver is not the thread owner"}
|
||||||
|
|
||||||
|
if workflow_action not in {"approve", "reject"}:
|
||||||
|
return {"status": "ignored", "reason": "Unknown workflow approval action"}
|
||||||
|
approved = workflow_action == "approve"
|
||||||
|
record = await common.decide_workflow_push_approval(
|
||||||
|
thread_id, fingerprint, approved=approved, actor=user_id
|
||||||
|
)
|
||||||
|
if record is None:
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text="I couldn't find that workflow approval request. Trigger the push again to create a fresh approval.",
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "workflow approval not found"}
|
||||||
|
if not approved:
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text=f"Workflow push rejected for fingerprint `{fingerprint}`. No workflow files will be pushed.",
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Workflow push rejected"}
|
||||||
|
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text=f"Workflow push approved for fingerprint `{fingerprint}`. Open SWE will retry the blocked push.",
|
||||||
|
)
|
||||||
|
channel_context = await common._get_slack_channel_context(channel_id)
|
||||||
|
repo_config = await common.get_slack_repo_config(
|
||||||
|
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
|
||||||
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
service.process_slack_mention,
|
||||||
|
{
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"channel_context": channel_context,
|
||||||
|
"thread_ts": thread_ts,
|
||||||
|
"event_ts": str(message.get("ts") or ""),
|
||||||
|
"user_id": user_id,
|
||||||
|
"text": (
|
||||||
|
"The workflow-file push approval was approved. Retry the blocked "
|
||||||
|
"git push now; do not alter workflow files before pushing."
|
||||||
|
),
|
||||||
|
"bot_user_id": common.SLACK_BOT_USER_ID,
|
||||||
|
},
|
||||||
|
repo_config,
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Workflow push approved, retry queued"}
|
||||||
|
|
||||||
|
if action_value.get("type") == "plan_approval":
|
||||||
|
plan_action = str(action_value.get("action") or "").strip()
|
||||||
|
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
|
||||||
|
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
|
||||||
|
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
|
||||||
|
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||||
|
channel_id = str(channel.get("id") or container.get("channel_id") or "")
|
||||||
|
thread_ts = str(
|
||||||
|
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
|
||||||
|
)
|
||||||
|
user_id = str(user.get("id") or "")
|
||||||
|
if not channel_id or not thread_ts:
|
||||||
|
return {"status": "ignored", "reason": "Missing Slack action context"}
|
||||||
|
|
||||||
|
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||||
|
|
||||||
|
if plan_action == "cancel":
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text="Plan cancelled. No changes will be made.",
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Plan cancelled"}
|
||||||
|
|
||||||
|
if plan_action == "approve":
|
||||||
|
if not await common._slack_user_is_thread_owner(thread_id, user_id):
|
||||||
|
await common.post_slack_thread_reply(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=thread_ts,
|
||||||
|
text="Only the person who requested this plan can approve it. Anyone can reply with feedback or use *Revise Plan*.",
|
||||||
|
)
|
||||||
|
return {"status": "ignored", "reason": "approver is not the thread owner"}
|
||||||
|
await common._set_thread_plan_mode(thread_id, False)
|
||||||
|
channel_context = await common._get_slack_channel_context(channel_id)
|
||||||
|
repo_config = await common.get_slack_repo_config(
|
||||||
|
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
|
||||||
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
service.process_slack_mention,
|
||||||
|
{
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"channel_context": channel_context,
|
||||||
|
"thread_ts": thread_ts,
|
||||||
|
"event_ts": str(message.get("ts") or ""),
|
||||||
|
"user_id": user_id,
|
||||||
|
"text": "Proceed with the approved plan. Implement the changes as described in the plan.",
|
||||||
|
"bot_user_id": common.SLACK_BOT_USER_ID,
|
||||||
|
},
|
||||||
|
repo_config,
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Plan approved, starting implementation"}
|
||||||
|
|
||||||
|
return {"status": "accepted", "message": "Reply to revise the plan"}
|
||||||
|
|
||||||
|
if action_value.get("type") != "open_swe_option":
|
||||||
|
return {"status": "ignored", "reason": "Unknown action type"}
|
||||||
|
|
||||||
|
response = str(action_value.get("response") or "").strip()
|
||||||
|
if not response:
|
||||||
|
return {"status": "ignored", "reason": "Empty response"}
|
||||||
|
|
||||||
|
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
|
||||||
|
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
|
||||||
|
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
|
||||||
|
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||||
|
channel_id = str(channel.get("id") or container.get("channel_id") or "")
|
||||||
|
event_ts = str(
|
||||||
|
action.get("action_ts") or message.get("ts") or container.get("message_ts") or ""
|
||||||
|
)
|
||||||
|
thread_ts = str(
|
||||||
|
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or event_ts
|
||||||
|
)
|
||||||
|
user_id = str(user.get("id") or "")
|
||||||
|
if not channel_id or not thread_ts or not event_ts or not user_id:
|
||||||
|
return {"status": "ignored", "reason": "Missing Slack action context"}
|
||||||
|
|
||||||
|
channel_context = await common._get_slack_channel_context(channel_id)
|
||||||
|
repo_config = await common.get_slack_repo_config(
|
||||||
|
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
|
||||||
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
service.process_slack_mention,
|
||||||
|
{
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"channel_context": channel_context,
|
||||||
|
"thread_ts": thread_ts,
|
||||||
|
"event_ts": event_ts,
|
||||||
|
"user_id": user_id,
|
||||||
|
"text": response,
|
||||||
|
"bot_user_id": common.SLACK_BOT_USER_ID,
|
||||||
|
},
|
||||||
|
repo_config,
|
||||||
|
)
|
||||||
|
return {"status": "accepted", "message": "Slack option queued"}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_open_swe_option_action(actions: common.Any) -> dict[str, common.Any] | None:
|
||||||
|
if not isinstance(actions, list):
|
||||||
|
return None
|
||||||
|
for action in actions:
|
||||||
|
if isinstance(action, dict) and action.get("action_id") == "open_swe_option_select":
|
||||||
|
return action
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/slack")
|
||||||
|
async def slack_webhook_verify() -> dict[str, str]:
|
||||||
|
"""Verify endpoint for Slack webhook setup."""
|
||||||
|
return {"status": "ok", "message": "Slack webhook endpoint is active"}
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -15,10 +15,10 @@ def _default_enable_auto_review(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
is empty in the test environment because there is no live LangGraph Store.
|
is empty in the test environment because there is no live LangGraph Store.
|
||||||
|
|
||||||
Tests targeting the automatic-review gate should override this fixture or set
|
Tests targeting the automatic-review gate should override this fixture or set
|
||||||
``monkeypatch.setattr(webapp, "is_review_repo_enabled", ...)`` to a stricter stub.
|
``monkeypatch.setattr(webhook_common, "is_review_repo_enabled", ...)`` to a stricter stub.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def _enabled(_owner: str, _name: str) -> bool:
|
async def _enabled(_owner: str, _name: str) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "is_review_repo_enabled", _enabled)
|
monkeypatch.setattr(webhook_common, "is_review_repo_enabled", _enabled)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ code runs for real.
|
||||||
|
|
||||||
| Piece | Real or fake |
|
| Piece | Real or fake |
|
||||||
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.api.app`) |
|
||||||
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
||||||
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
||||||
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
||||||
|
|
@ -35,7 +35,7 @@ so what Playwright asserts on is exactly what the real agent produced.
|
||||||
- `patches.py` — monkeypatches the boundaries (LLM, GitHub/Slack URLs, token mint).
|
- `patches.py` — monkeypatches the boundaries (LLM, GitHub/Slack URLs, token mint).
|
||||||
- `agent_entrypoint.py` — langgraph `agent` graph: applies patches, re-exports the
|
- `agent_entrypoint.py` — langgraph `agent` graph: applies patches, re-exports the
|
||||||
real `traced_agent`.
|
real `traced_agent`.
|
||||||
- `harness.py` — langgraph `http.app`: the real `agent.webapp` plus the fake
|
- `harness.py` — langgraph `http.app`: the real `agent.api.app` plus the fake
|
||||||
GitHub/Slack APIs, the mock UIs, and the control/compose endpoints.
|
GitHub/Slack APIs, the mock UIs, and the control/compose endpoints.
|
||||||
- `fakes.py` — in-memory PR/Slack stores + git seeding of the bare remote.
|
- `fakes.py` — in-memory PR/Slack stores + git seeding of the bare remote.
|
||||||
- `langgraph.e2e.json` — dev-server config pointing at the two entrypoints above.
|
- `langgraph.e2e.json` — dev-server config pointing at the two entrypoints above.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Shared environment + constants for the full-flow E2E.
|
"""Shared environment + constants for the full-flow E2E.
|
||||||
|
|
||||||
Imported FIRST by both the agent graph entrypoint and the HTTP harness, before
|
Imported FIRST by both the agent graph entrypoint and the HTTP harness, before
|
||||||
any ``agent.*`` module — several webapp/auth/slack constants are read into module
|
any ``agent.*`` module — several webhook/auth/slack constants are read into module
|
||||||
globals at import time, so the env must be set beforehand.
|
globals at import time, so the env must be set beforehand.
|
||||||
|
|
||||||
Everything here only configures *boundaries* (which sandbox, which fake API
|
Everything here only configures *boundaries* (which sandbox, which fake API
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""HTTP app for the full-flow E2E (served as langgraph dev's http.app).
|
"""HTTP app for the full-flow E2E (served as langgraph dev's http.app).
|
||||||
|
|
||||||
Mounts, on top of the REAL ``agent.webapp`` app:
|
Mounts, on top of the REAL ``agent.api.app`` app:
|
||||||
- fake GitHub REST API (/fake-gh/...) the real open_pull_request hits this
|
- fake GitHub REST API (/fake-gh/...) the real open_pull_request hits this
|
||||||
- fake Slack API (/fake-slack/...) the real slack code hits this
|
- fake Slack API (/fake-slack/...) the real slack code hits this
|
||||||
- mock UIs (/mock/slack, /mock/github) what the user/Playwright sees
|
- mock UIs (/mock/slack, /mock/github) what the user/Playwright sees
|
||||||
|
|
@ -59,8 +59,9 @@ _SLACK_USERS: dict[str, dict[str, str]] = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from agent.api.app import app # noqa: E402
|
||||||
from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402
|
from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402
|
||||||
from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402
|
from agent.utils.thread_ids import generate_thread_id_from_slack_thread # noqa: E402
|
||||||
|
|
||||||
GITHUB_WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
|
GITHUB_WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
|
||||||
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
|
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def test_account_link_prompt_posts_generic_token_free_link(
|
||||||
"""The prompt posts a plain settings link in the thread — no per-user token."""
|
"""The prompt posts a plain settings link in the thread — no per-user token."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||||
calls: dict[str, object] = {}
|
calls: dict[str, object] = {}
|
||||||
|
|
@ -25,9 +25,11 @@ def test_account_link_prompt_posts_generic_token_free_link(
|
||||||
calls["reply"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text}
|
calls["reply"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", fake_reply)
|
||||||
|
|
||||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
asyncio.run(
|
||||||
|
webhook_common._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked")
|
||||||
|
)
|
||||||
assert calls["reply"]["channel_id"] == "C1"
|
assert calls["reply"]["channel_id"] == "C1"
|
||||||
assert calls["reply"]["thread_ts"] == "1.1"
|
assert calls["reply"]["thread_ts"] == "1.1"
|
||||||
assert "https://app.example.com/my-settings" in calls["reply"]["text"]
|
assert "https://app.example.com/my-settings" in calls["reply"]["text"]
|
||||||
|
|
@ -38,7 +40,7 @@ def test_account_link_prompt_posts_generic_token_free_link(
|
||||||
def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||||
calls: dict[str, object] = {}
|
calls: dict[str, object] = {}
|
||||||
|
|
@ -47,9 +49,11 @@ def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
calls["text"] = text
|
calls["text"] = text
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", fake_reply)
|
||||||
|
|
||||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="revoked"))
|
asyncio.run(
|
||||||
|
webhook_common._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="revoked")
|
||||||
|
)
|
||||||
assert "no longer valid" in calls["text"]
|
assert "no longer valid" in calls["text"]
|
||||||
assert "link=" not in calls["text"]
|
assert "link=" not in calls["text"]
|
||||||
|
|
||||||
|
|
@ -59,7 +63,7 @@ def test_account_link_prompt_skips_when_dashboard_url_unset(
|
||||||
) -> None:
|
) -> None:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
||||||
posted = False
|
posted = False
|
||||||
|
|
@ -69,7 +73,9 @@ def test_account_link_prompt_skips_when_dashboard_url_unset(
|
||||||
posted = True
|
posted = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_reply)
|
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", fake_reply)
|
||||||
|
|
||||||
asyncio.run(webapp._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked"))
|
asyncio.run(
|
||||||
|
webhook_common._post_account_link_prompt("C1", "1.1", "U1", "d@x.com", reason="unlinked")
|
||||||
|
)
|
||||||
assert posted is False
|
assert posted is False
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
|
|
||||||
def _pr_payload(*, state: str, merged: bool = False, draft: bool = False) -> dict[str, Any]:
|
def _pr_payload(*, state: str, merged: bool = False, draft: bool = False) -> dict[str, Any]:
|
||||||
|
|
@ -22,23 +22,25 @@ def _pr_payload(*, state: str, merged: bool = False, draft: bool = False) -> dic
|
||||||
|
|
||||||
|
|
||||||
def test_pr_state_from_payload_merged() -> None:
|
def test_pr_state_from_payload_merged() -> None:
|
||||||
assert webapp._pr_state_from_payload(_pr_payload(state="closed", merged=True)) == "merged"
|
assert (
|
||||||
|
webhook_common._pr_state_from_payload(_pr_payload(state="closed", merged=True)) == "merged"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_pr_state_from_payload_closed() -> None:
|
def test_pr_state_from_payload_closed() -> None:
|
||||||
assert webapp._pr_state_from_payload(_pr_payload(state="closed")) == "closed"
|
assert webhook_common._pr_state_from_payload(_pr_payload(state="closed")) == "closed"
|
||||||
|
|
||||||
|
|
||||||
def test_pr_state_from_payload_draft() -> None:
|
def test_pr_state_from_payload_draft() -> None:
|
||||||
assert webapp._pr_state_from_payload(_pr_payload(state="open", draft=True)) == "draft"
|
assert webhook_common._pr_state_from_payload(_pr_payload(state="open", draft=True)) == "draft"
|
||||||
|
|
||||||
|
|
||||||
def test_pr_state_from_payload_open() -> None:
|
def test_pr_state_from_payload_open() -> None:
|
||||||
assert webapp._pr_state_from_payload(_pr_payload(state="open")) == "open"
|
assert webhook_common._pr_state_from_payload(_pr_payload(state="open")) == "open"
|
||||||
|
|
||||||
|
|
||||||
def test_pr_state_from_payload_missing_pull_request() -> None:
|
def test_pr_state_from_payload_missing_pull_request() -> None:
|
||||||
assert webapp._pr_state_from_payload({}) is None
|
assert webhook_common._pr_state_from_payload({}) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -54,8 +56,8 @@ async def test_update_agent_thread_pr_state_updates_matching_thread() -> None:
|
||||||
)
|
)
|
||||||
fake_client.threads.update = AsyncMock()
|
fake_client.threads.update = AsyncMock()
|
||||||
|
|
||||||
with patch("agent.webapp.get_client", return_value=fake_client):
|
with patch("agent.webhooks.common.get_client", return_value=fake_client):
|
||||||
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed"))
|
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed"))
|
||||||
|
|
||||||
fake_client.threads.search.assert_awaited_once()
|
fake_client.threads.search.assert_awaited_once()
|
||||||
fake_client.threads.update.assert_awaited_once()
|
fake_client.threads.update.assert_awaited_once()
|
||||||
|
|
@ -71,8 +73,8 @@ async def test_update_agent_thread_pr_state_skips_reviewer_threads() -> None:
|
||||||
)
|
)
|
||||||
fake_client.threads.update = AsyncMock()
|
fake_client.threads.update = AsyncMock()
|
||||||
|
|
||||||
with patch("agent.webapp.get_client", return_value=fake_client):
|
with patch("agent.webhooks.common.get_client", return_value=fake_client):
|
||||||
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed"))
|
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed"))
|
||||||
|
|
||||||
fake_client.threads.update.assert_not_called()
|
fake_client.threads.update.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -85,7 +87,7 @@ async def test_update_agent_thread_pr_state_noop_when_state_unchanged() -> None:
|
||||||
)
|
)
|
||||||
fake_client.threads.update = AsyncMock()
|
fake_client.threads.update = AsyncMock()
|
||||||
|
|
||||||
with patch("agent.webapp.get_client", return_value=fake_client):
|
with patch("agent.webhooks.common.get_client", return_value=fake_client):
|
||||||
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed", merged=True))
|
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed", merged=True))
|
||||||
|
|
||||||
fake_client.threads.update.assert_not_called()
|
fake_client.threads.update.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Unit tests for the auto-fix webhook helpers in agent.webapp."""
|
"""Unit tests for the auto-fix webhook helpers in the webhook layer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,18 +6,19 @@ from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
from agent.webhooks import github as webhooks_github
|
from agent.webhooks import github as webhooks_github
|
||||||
|
|
||||||
|
|
||||||
def test_parse_autofix_command() -> None:
|
def test_parse_autofix_command() -> None:
|
||||||
assert webapp._parse_autofix_command("@open-swe autofix off") is True
|
assert github_webhooks._parse_autofix_command("@open-swe autofix off") is True
|
||||||
assert webapp._parse_autofix_command("@open-swe autofix on") is False
|
assert github_webhooks._parse_autofix_command("@open-swe autofix on") is False
|
||||||
assert webapp._parse_autofix_command("@openswe please autofix off now") is True
|
assert github_webhooks._parse_autofix_command("@openswe please autofix off now") is True
|
||||||
# Missing the mention -> not a command.
|
# Missing the mention -> not a command.
|
||||||
assert webapp._parse_autofix_command("autofix off") is None
|
assert github_webhooks._parse_autofix_command("autofix off") is None
|
||||||
# Mention but no command keyword.
|
# Mention but no command keyword.
|
||||||
assert webapp._parse_autofix_command("@open-swe fix this") is None
|
assert github_webhooks._parse_autofix_command("@open-swe fix this") is None
|
||||||
|
|
||||||
|
|
||||||
def test_pr_ref_from_issue_comment() -> None:
|
def test_pr_ref_from_issue_comment() -> None:
|
||||||
|
|
@ -28,7 +29,7 @@ def test_pr_ref_from_issue_comment() -> None:
|
||||||
"pull_request": {"html_url": "https://github.com/o/r/pull/7"},
|
"pull_request": {"html_url": "https://github.com/o/r/pull/7"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ref = webapp._pr_ref_from_comment_payload(payload, "issue_comment")
|
ref = github_webhooks._pr_ref_from_comment_payload(payload, "issue_comment")
|
||||||
assert ref == {"owner": "o", "name": "r", "number": 7, "url": "https://github.com/o/r/pull/7"}
|
assert ref == {"owner": "o", "name": "r", "number": 7, "url": "https://github.com/o/r/pull/7"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,19 +38,19 @@ def test_pr_ref_from_review_comment() -> None:
|
||||||
"repository": {"owner": {"login": "o"}, "name": "r"},
|
"repository": {"owner": {"login": "o"}, "name": "r"},
|
||||||
"pull_request": {"number": 9, "html_url": "https://github.com/o/r/pull/9"},
|
"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")
|
ref = github_webhooks._pr_ref_from_comment_payload(payload, "pull_request_review_comment")
|
||||||
assert ref["number"] == 9
|
assert ref["number"] == 9
|
||||||
|
|
||||||
|
|
||||||
def test_pr_ref_none_when_not_a_pr() -> None:
|
def test_pr_ref_none_when_not_a_pr() -> None:
|
||||||
payload = {"repository": {"owner": {"login": "o"}, "name": "r"}, "issue": {"number": 3}}
|
payload = {"repository": {"owner": {"login": "o"}, "name": "r"}, "issue": {"number": 3}}
|
||||||
# issue without pull_request still yields a ref (number present); url empty.
|
# issue without pull_request still yields a ref (number present); url empty.
|
||||||
ref = webapp._pr_ref_from_comment_payload(payload, "issue_comment")
|
ref = github_webhooks._pr_ref_from_comment_payload(payload, "issue_comment")
|
||||||
assert ref["url"] == ""
|
assert ref["url"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_is_actionable_review_payload() -> None:
|
def test_is_actionable_review_payload() -> None:
|
||||||
assert webapp._is_actionable_review_payload(
|
assert github_webhooks._is_actionable_review_payload(
|
||||||
{
|
{
|
||||||
"action": "submitted",
|
"action": "submitted",
|
||||||
"review": {
|
"review": {
|
||||||
|
|
@ -62,7 +63,7 @@ def test_is_actionable_review_payload() -> None:
|
||||||
"pull_request_review",
|
"pull_request_review",
|
||||||
)
|
)
|
||||||
# Approval is not actionable.
|
# Approval is not actionable.
|
||||||
assert not webapp._is_actionable_review_payload(
|
assert not github_webhooks._is_actionable_review_payload(
|
||||||
{
|
{
|
||||||
"action": "submitted",
|
"action": "submitted",
|
||||||
"review": {
|
"review": {
|
||||||
|
|
@ -75,7 +76,7 @@ def test_is_actionable_review_payload() -> None:
|
||||||
"pull_request_review",
|
"pull_request_review",
|
||||||
)
|
)
|
||||||
# Bot author is not actionable.
|
# Bot author is not actionable.
|
||||||
assert not webapp._is_actionable_review_payload(
|
assert not github_webhooks._is_actionable_review_payload(
|
||||||
{
|
{
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"comment": {
|
"comment": {
|
||||||
|
|
@ -87,7 +88,7 @@ def test_is_actionable_review_payload() -> None:
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
)
|
)
|
||||||
# Untrusted author (read/triage/outside) is not actionable.
|
# Untrusted author (read/triage/outside) is not actionable.
|
||||||
assert not webapp._is_actionable_review_payload(
|
assert not github_webhooks._is_actionable_review_payload(
|
||||||
{
|
{
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"comment": {
|
"comment": {
|
||||||
|
|
@ -99,7 +100,7 @@ def test_is_actionable_review_payload() -> None:
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
)
|
)
|
||||||
# Empty body is not actionable.
|
# Empty body is not actionable.
|
||||||
assert not webapp._is_actionable_review_payload(
|
assert not github_webhooks._is_actionable_review_payload(
|
||||||
{
|
{
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"comment": {"body": " ", "user": {"login": "a"}, "author_association": "OWNER"},
|
"comment": {"body": " ", "user": {"login": "a"}, "author_association": "OWNER"},
|
||||||
|
|
@ -121,7 +122,7 @@ async def test_process_github_ci_event_dispatches() -> None:
|
||||||
}
|
}
|
||||||
handle = AsyncMock(return_value="dispatched")
|
handle = AsyncMock(return_value="dispatched")
|
||||||
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
||||||
await webapp.process_github_ci_event(payload, "check_run")
|
await github_webhooks.process_github_ci_event(payload, "check_run")
|
||||||
handle.assert_awaited_once()
|
handle.assert_awaited_once()
|
||||||
kwargs = handle.await_args.kwargs
|
kwargs = handle.await_args.kwargs
|
||||||
assert kwargs["repo_config"] == {"owner": "o", "name": "r"}
|
assert kwargs["repo_config"] == {"owner": "o", "name": "r"}
|
||||||
|
|
@ -137,7 +138,7 @@ async def test_process_github_ci_event_ignores_success() -> None:
|
||||||
}
|
}
|
||||||
handle = AsyncMock()
|
handle = AsyncMock()
|
||||||
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
||||||
await webapp.process_github_ci_event(payload, "check_run")
|
await github_webhooks.process_github_ci_event(payload, "check_run")
|
||||||
handle.assert_not_called()
|
handle.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,9 +152,13 @@ async def test_process_autofix_command_sets_flag() -> None:
|
||||||
setter = AsyncMock()
|
setter = AsyncMock()
|
||||||
with (
|
with (
|
||||||
patch.object(webhooks_github, "set_pr_autofix_disabled", setter),
|
patch.object(webhooks_github, "set_pr_autofix_disabled", setter),
|
||||||
patch.object(webapp, "get_github_app_installation_token", AsyncMock(return_value="")),
|
patch.object(
|
||||||
|
webhook_common, "get_github_app_installation_token", AsyncMock(return_value="")
|
||||||
|
),
|
||||||
):
|
):
|
||||||
await webapp.process_github_autofix_command(payload, "issue_comment", disabled=True)
|
await github_webhooks.process_github_autofix_command(
|
||||||
|
payload, "issue_comment", disabled=True
|
||||||
|
)
|
||||||
setter.assert_awaited_once_with("o", "r", 7, True)
|
setter.assert_awaited_once_with("o", "r", 7, True)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -166,7 +171,7 @@ async def test_autofix_review_dispatches_for_writer() -> None:
|
||||||
}
|
}
|
||||||
handle = AsyncMock(return_value="dispatched")
|
handle = AsyncMock(return_value="dispatched")
|
||||||
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
||||||
await webapp.process_github_autofix_review(payload, "pull_request_review")
|
await github_webhooks.process_github_autofix_review(payload, "pull_request_review")
|
||||||
handle.assert_awaited_once()
|
handle.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -179,11 +184,11 @@ async def test_autofix_review_delegates_permission_check_to_core() -> None:
|
||||||
}
|
}
|
||||||
handle = AsyncMock(return_value="reviewer_no_write_permission")
|
handle = AsyncMock(return_value="reviewer_no_write_permission")
|
||||||
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
||||||
await webapp.process_github_autofix_review(payload, "pull_request_review")
|
await github_webhooks.process_github_autofix_review(payload, "pull_request_review")
|
||||||
handle.assert_awaited_once()
|
handle.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
def test_ci_events_supported() -> None:
|
def test_ci_events_supported() -> None:
|
||||||
for event in ("check_run", "check_suite", "workflow_run", "status"):
|
for event in ("check_run", "check_suite", "workflow_run", "status"):
|
||||||
assert event in webapp._SUPPORTED_GH_EVENTS
|
assert event in webhook_common._SUPPORTED_GH_EVENTS
|
||||||
assert event in webapp._GH_CI_EVENTS
|
assert event in webhook_common._GH_CI_EVENTS
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.utils import atlassian_connect as ac
|
from agent.utils import atlassian_connect as ac
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
from agent.webhooks import confluence as cf
|
from agent.webhooks import confluence as cf
|
||||||
|
from agent.webhooks import confluence_routes
|
||||||
|
|
||||||
|
|
||||||
def _req() -> object:
|
def _req() -> object:
|
||||||
|
|
@ -207,25 +208,33 @@ def _run_comment(payload: dict, server_comment: dict | None, *, active: set[str]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
webapp, "fetch_confluence_comment", new=AsyncMock(return_value=server_comment)
|
webhook_common, "fetch_confluence_comment", new=AsyncMock(return_value=server_comment)
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
webapp, "fetch_confluence_page", new=AsyncMock(return_value={"title": "P", "url": "u"})
|
webhook_common,
|
||||||
|
"fetch_confluence_page",
|
||||||
|
new=AsyncMock(return_value={"title": "P", "url": "u"}),
|
||||||
),
|
),
|
||||||
patch.object(webapp, "get_confluence_user_email", new=AsyncMock(return_value="jane@x.com")),
|
|
||||||
patch.object(webapp, "resolve_login_from_email_async", new=AsyncMock(return_value="jane")),
|
|
||||||
patch.object(webapp, "is_login_mapped", side_effect=lambda login: login in active),
|
|
||||||
patch.object(
|
patch.object(
|
||||||
webapp,
|
webhook_common, "get_confluence_user_email", new=AsyncMock(return_value="jane@x.com")
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
webhook_common, "resolve_login_from_email_async", new=AsyncMock(return_value="jane")
|
||||||
|
),
|
||||||
|
patch.object(webhook_common, "is_login_mapped", side_effect=lambda login: login in active),
|
||||||
|
patch.object(
|
||||||
|
webhook_common,
|
||||||
"get_repo_config_from_confluence_mapping",
|
"get_repo_config_from_confluence_mapping",
|
||||||
return_value={"owner": "o", "name": "n"},
|
return_value={"owner": "o", "name": "n"},
|
||||||
),
|
),
|
||||||
patch.object(webapp, "_is_repo_allowed", return_value=True),
|
patch.object(webhook_common, "_is_repo_allowed", return_value=True),
|
||||||
patch.object(webapp, "generate_thread_id_from_confluence_comment", return_value="th-1"),
|
|
||||||
patch.object(
|
patch.object(
|
||||||
webapp, "upsert_agent_thread_owner_metadata", new=AsyncMock(return_value=None)
|
webhook_common, "generate_thread_id_from_confluence_comment", return_value="th-1"
|
||||||
),
|
),
|
||||||
patch.object(webapp, "dispatch_agent_run", side_effect=fake_dispatch),
|
patch.object(
|
||||||
|
webhook_common, "upsert_agent_thread_owner_metadata", new=AsyncMock(return_value=None)
|
||||||
|
),
|
||||||
|
patch.object(webhook_common, "dispatch_agent_run", side_effect=fake_dispatch),
|
||||||
):
|
):
|
||||||
asyncio.run(cf.process_confluence_comment(payload))
|
asyncio.run(cf.process_confluence_comment(payload))
|
||||||
return captured
|
return captured
|
||||||
|
|
@ -278,7 +287,7 @@ def test_webhook_bot_own_comment_dropped() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_descriptor_signed_install_true_and_read_scope() -> None:
|
def test_descriptor_signed_install_true_and_read_scope() -> None:
|
||||||
desc = asyncio.run(webapp.connect_descriptor())
|
desc = asyncio.run(confluence_routes.connect_descriptor())
|
||||||
assert desc["apiMigrations"]["signed-install"] is True
|
assert desc["apiMigrations"]["signed-install"] is True
|
||||||
assert desc["scopes"] == ["READ"]
|
assert desc["scopes"] == ["READ"]
|
||||||
assert desc["authentication"]["type"] == "jwt"
|
assert desc["authentication"]["type"] == "jwt"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.dashboard.agent_overrides import profile_create_prs
|
from agent.dashboard.agent_overrides import profile_create_prs
|
||||||
from agent.prompt import construct_system_prompt
|
from agent.prompt import construct_system_prompt
|
||||||
from agent.utils import github_comments
|
from agent.utils import github_comments
|
||||||
|
|
@ -10,6 +9,7 @@ from agent.utils.authorship import (
|
||||||
CollaboratorIdentity,
|
CollaboratorIdentity,
|
||||||
resolve_triggering_user_identity,
|
resolve_triggering_user_identity,
|
||||||
)
|
)
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
|
||||||
_BOT_TRAILER = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
|
_BOT_TRAILER = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
|
||||||
|
|
||||||
|
|
@ -372,7 +372,7 @@ def test_build_github_issue_prompt_only_wraps_external_comments() -> None:
|
||||||
[{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}]
|
[{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}]
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
prompt = webapp.build_github_issue_prompt(
|
prompt = github_webhooks.build_github_issue_prompt(
|
||||||
{"owner": "langchain-ai", "name": "open-swe"},
|
{"owner": "langchain-ai", "name": "open-swe"},
|
||||||
42,
|
42,
|
||||||
"12345",
|
"12345",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.utils import github_feedback
|
from agent.utils import github_feedback
|
||||||
from agent.utils.github_feedback import (
|
from agent.utils.github_feedback import (
|
||||||
process_github_reaction_added,
|
process_github_reaction_added,
|
||||||
process_github_reaction_removed,
|
process_github_reaction_removed,
|
||||||
)
|
)
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github_routes
|
||||||
|
|
||||||
|
|
||||||
class _FakeStore:
|
class _FakeStore:
|
||||||
|
|
@ -176,9 +177,9 @@ async def test_github_webhook_ignores_reaction_event(monkeypatch: pytest.MonkeyP
|
||||||
payload = _reaction_payload()
|
payload = _reaction_payload()
|
||||||
background_tasks = _FakeBackgroundTasks()
|
background_tasks = _FakeBackgroundTasks()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "verify_github_signature", lambda *args, **kwargs: True)
|
monkeypatch.setattr(webhook_common, "verify_github_signature", lambda *args, **kwargs: True)
|
||||||
|
|
||||||
response = await webapp.github_webhook(_FakeRequest(payload), background_tasks)
|
response = await github_routes.github_webhook(_FakeRequest(payload), background_tasks)
|
||||||
|
|
||||||
assert response == {"status": "ignored", "reason": "Unsupported event type: reaction"}
|
assert response == {"status": "ignored", "reason": "Unsupported event type: reaction"}
|
||||||
assert background_tasks.tasks == []
|
assert background_tasks.tasks == []
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,13 @@ import logging
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from agent import webapp
|
from agent.api import app as api_app
|
||||||
from agent.tools import request_pr_review as request_pr_review_tool
|
from agent.tools import request_pr_review as request_pr_review_tool
|
||||||
from agent.utils import slack as slack_utils
|
from agent.utils import slack as slack_utils
|
||||||
from agent.utils.slack import GitHubPrRef
|
from agent.utils.slack import GitHubPrRef
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
from agent.webhooks import slack as slack_webhooks
|
||||||
|
|
||||||
request_pr_review_module = importlib.import_module("agent.tools.request_pr_review")
|
request_pr_review_module = importlib.import_module("agent.tools.request_pr_review")
|
||||||
|
|
||||||
|
|
@ -61,15 +64,15 @@ def _post_slack_webhook(client: TestClient, payload: dict) -> object:
|
||||||
|
|
||||||
|
|
||||||
def test_generate_thread_id_from_github_issue_is_deterministic() -> None:
|
def test_generate_thread_id_from_github_issue_is_deterministic() -> None:
|
||||||
first = webapp.generate_thread_id_from_github_issue("12345")
|
first = webhook_common.generate_thread_id_from_github_issue("12345")
|
||||||
second = webapp.generate_thread_id_from_github_issue("12345")
|
second = webhook_common.generate_thread_id_from_github_issue("12345")
|
||||||
|
|
||||||
assert first == second
|
assert first == second
|
||||||
assert len(first) == 36
|
assert len(first) == 36
|
||||||
|
|
||||||
|
|
||||||
def test_build_github_issue_prompt_includes_issue_context() -> None:
|
def test_build_github_issue_prompt_includes_issue_context() -> None:
|
||||||
prompt = webapp.build_github_issue_prompt(
|
prompt = github_webhooks.build_github_issue_prompt(
|
||||||
{"owner": "langchain-ai", "name": "open-swe"},
|
{"owner": "langchain-ai", "name": "open-swe"},
|
||||||
42,
|
42,
|
||||||
"12345",
|
"12345",
|
||||||
|
|
@ -96,7 +99,9 @@ def test_build_github_issue_followup_prompt_only_includes_comment() -> None:
|
||||||
[{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}]
|
[{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}]
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
prompt = webapp.build_github_issue_followup_prompt("bracesproul", "Please handle this")
|
prompt = github_webhooks.build_github_issue_followup_prompt(
|
||||||
|
"bracesproul", "Please handle this"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
user_mappings.clear_cache()
|
user_mappings.clear_cache()
|
||||||
|
|
||||||
|
|
@ -113,18 +118,22 @@ def test_auto_review_enablement_uses_dashboard_opt_in(monkeypatch) -> None:
|
||||||
seen["name"] = name
|
seen["name"] = name
|
||||||
return owner == "langchain-ai" and name == "open-swe-app"
|
return owner == "langchain-ai" and name == "open-swe-app"
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "is_review_repo_enabled", fake_is_review_repo_enabled)
|
monkeypatch.setattr(webhook_common, "is_review_repo_enabled", fake_is_review_repo_enabled)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp._is_repo_auto_review_enabled({"owner": "langchain-ai", "name": "open-swe-app"})
|
webhook_common._is_repo_auto_review_enabled(
|
||||||
|
{"owner": "langchain-ai", "name": "open-swe-app"}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
is True
|
is True
|
||||||
)
|
)
|
||||||
assert seen == {"owner": "langchain-ai", "name": "open-swe-app"}
|
assert seen == {"owner": "langchain-ai", "name": "open-swe-app"}
|
||||||
assert (
|
assert (
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp._is_repo_auto_review_enabled({"owner": "langchain-ai", "name": "open-swe"})
|
webhook_common._is_repo_auto_review_enabled(
|
||||||
|
{"owner": "langchain-ai", "name": "open-swe"}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
is False
|
is False
|
||||||
)
|
)
|
||||||
|
|
@ -140,11 +149,11 @@ def test_github_webhook_skips_automatic_review_when_disabled(monkeypatch) -> Non
|
||||||
nonlocal called
|
nonlocal called
|
||||||
called = True
|
called = True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
monkeypatch.setattr(webhook_common, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_ready", fake_process_github_pr_ready)
|
monkeypatch.setattr(github_webhooks, "process_github_pr_ready", fake_process_github_pr_ready)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request",
|
"pull_request",
|
||||||
|
|
@ -170,10 +179,10 @@ def test_github_webhook_accepts_issue_events(monkeypatch) -> None:
|
||||||
called["payload"] = payload
|
called["payload"] = payload
|
||||||
called["event_type"] = event_type
|
called["event_type"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issues",
|
"issues",
|
||||||
|
|
@ -202,10 +211,10 @@ def test_github_webhook_ignores_issue_events_without_body_or_title_change(monkey
|
||||||
nonlocal called
|
nonlocal called
|
||||||
called = True
|
called = True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issues",
|
"issues",
|
||||||
|
|
@ -235,10 +244,10 @@ def test_github_webhook_accepts_issue_comment_events(monkeypatch) -> None:
|
||||||
called["payload"] = payload
|
called["payload"] = payload
|
||||||
called["event_type"] = event_type
|
called["event_type"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -260,11 +269,13 @@ def test_github_webhook_ignores_unmentioned_comment_without_info_log(monkeypatch
|
||||||
async def fake_process_github_pr_comment(payload: dict[str, object], event_type: str) -> None:
|
async def fake_process_github_pr_comment(payload: dict[str, object], event_type: str) -> None:
|
||||||
raise AssertionError("process_github_pr_comment should not be called")
|
raise AssertionError("process_github_pr_comment should not be called")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
caplog.set_level(logging.INFO, logger=webapp.logger.name)
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
caplog.set_level(logging.INFO, logger=webhook_common.logger.name)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
|
|
@ -303,12 +314,14 @@ def test_github_webhook_routes_review_comment_reply_without_tag(monkeypatch) ->
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "process_github_review_finding_reply", fake_process_github_review_finding_reply
|
github_webhooks,
|
||||||
|
"process_github_review_finding_reply",
|
||||||
|
fake_process_github_review_finding_reply,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
monkeypatch.setattr(webhook_common, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
|
|
@ -341,7 +354,7 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def fake_get_thread_metadata_safe(_thread_id: str) -> dict[str, object]:
|
async def fake_get_thread_metadata_safe(_thread_id: str) -> dict[str, object]:
|
||||||
return {"kind": webapp.REVIEWER_THREAD_KIND}
|
return {"kind": webhook_common.REVIEWER_THREAD_KIND}
|
||||||
|
|
||||||
async def fake_get_token_with_expiry() -> tuple[str, str]:
|
async def fake_get_token_with_expiry() -> tuple[str, str]:
|
||||||
return "app-token", "2026-01-01T00:00:00Z"
|
return "app-token", "2026-01-01T00:00:00Z"
|
||||||
|
|
@ -377,20 +390,20 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
|
||||||
class _FakeLangGraphClient:
|
class _FakeLangGraphClient:
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_get_thread_metadata_safe", fake_get_thread_metadata_safe)
|
monkeypatch.setattr(webhook_common, "_get_thread_metadata_safe", fake_get_thread_metadata_safe)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token_with_expiry", fake_get_token_with_expiry
|
webhook_common, "get_github_app_installation_token_with_expiry", fake_get_token_with_expiry
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_token)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_token)
|
||||||
monkeypatch.setattr(webapp, "fetch_pr_review_threads", fake_fetch_threads)
|
monkeypatch.setattr(webhook_common, "fetch_pr_review_threads", fake_fetch_threads)
|
||||||
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
|
monkeypatch.setattr(webhook_common, "reconcile_findings_with_review_threads", fake_reconcile)
|
||||||
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
|
monkeypatch.setattr(webhook_common, "list_reviewer_findings", fake_list_findings)
|
||||||
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
|
monkeypatch.setattr(webhook_common, "append_finding_interaction", fake_append_interaction)
|
||||||
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
monkeypatch.setattr(webhook_common, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_review_finding_reply(
|
github_webhooks.process_github_review_finding_reply(
|
||||||
{
|
{
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 222,
|
"id": 222,
|
||||||
|
|
@ -422,7 +435,7 @@ def test_process_github_review_finding_reply_dispatches_sanitized_reply_body(mon
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
async def fake_get_thread_metadata_safe(_thread_id: str) -> dict[str, object]:
|
async def fake_get_thread_metadata_safe(_thread_id: str) -> dict[str, object]:
|
||||||
return {"kind": webapp.REVIEWER_THREAD_KIND}
|
return {"kind": webhook_common.REVIEWER_THREAD_KIND}
|
||||||
|
|
||||||
async def fake_get_token_with_expiry() -> tuple[str, str]:
|
async def fake_get_token_with_expiry() -> tuple[str, str]:
|
||||||
return "app-token", "2026-01-01T00:00:00Z"
|
return "app-token", "2026-01-01T00:00:00Z"
|
||||||
|
|
@ -455,20 +468,20 @@ def test_process_github_review_finding_reply_dispatches_sanitized_reply_body(mon
|
||||||
class _FakeLangGraphClient:
|
class _FakeLangGraphClient:
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_get_thread_metadata_safe", fake_get_thread_metadata_safe)
|
monkeypatch.setattr(webhook_common, "_get_thread_metadata_safe", fake_get_thread_metadata_safe)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token_with_expiry", fake_get_token_with_expiry
|
webhook_common, "get_github_app_installation_token_with_expiry", fake_get_token_with_expiry
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_token)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_token)
|
||||||
monkeypatch.setattr(webapp, "fetch_pr_review_threads", fake_fetch_threads)
|
monkeypatch.setattr(webhook_common, "fetch_pr_review_threads", fake_fetch_threads)
|
||||||
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
|
monkeypatch.setattr(webhook_common, "reconcile_findings_with_review_threads", fake_reconcile)
|
||||||
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
|
monkeypatch.setattr(webhook_common, "list_reviewer_findings", fake_list_findings)
|
||||||
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
|
monkeypatch.setattr(webhook_common, "append_finding_interaction", fake_append_interaction)
|
||||||
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
monkeypatch.setattr(webhook_common, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_review_finding_reply(
|
github_webhooks.process_github_review_finding_reply(
|
||||||
{
|
{
|
||||||
"comment": {
|
"comment": {
|
||||||
"id": 222,
|
"id": 222,
|
||||||
|
|
@ -503,10 +516,12 @@ def test_github_webhook_ignores_unsupported_comment_action(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(payload: dict[str, object], event_type: str) -> None:
|
async def fake_process_github_pr_comment(payload: dict[str, object], event_type: str) -> None:
|
||||||
raise AssertionError("process_github_pr_comment should not be called")
|
raise AssertionError("process_github_pr_comment should not be called")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request_review",
|
"pull_request_review",
|
||||||
|
|
@ -532,8 +547,8 @@ def test_github_webhook_ignores_unsupported_comment_action(monkeypatch) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_github_webhook_ignores_review_requested(monkeypatch) -> None:
|
def test_github_webhook_ignores_review_requested(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request",
|
"pull_request",
|
||||||
|
|
@ -563,9 +578,9 @@ def test_is_docs_plz_slack_channel_matches_name(monkeypatch) -> None:
|
||||||
assert channel_id == "C_DOCS"
|
assert channel_id == "C_DOCS"
|
||||||
return {"name": "docs-plz"}
|
return {"name": "docs-plz"}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_slack_channel_info", fake_get_slack_channel_info)
|
monkeypatch.setattr(webhook_common, "get_slack_channel_info", fake_get_slack_channel_info)
|
||||||
|
|
||||||
assert asyncio.run(webapp._is_docs_plz_slack_channel("C_DOCS")) is True
|
assert asyncio.run(webhook_common._is_docs_plz_slack_channel("C_DOCS")) is True
|
||||||
|
|
||||||
|
|
||||||
def test_is_docs_plz_slack_channel_matches_normalized_name(monkeypatch) -> None:
|
def test_is_docs_plz_slack_channel_matches_normalized_name(monkeypatch) -> None:
|
||||||
|
|
@ -573,9 +588,9 @@ def test_is_docs_plz_slack_channel_matches_normalized_name(monkeypatch) -> None:
|
||||||
assert channel_id == "C_DOCS"
|
assert channel_id == "C_DOCS"
|
||||||
return {"name": "Docs Plz", "name_normalized": "docs-plz"}
|
return {"name": "Docs Plz", "name_normalized": "docs-plz"}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_slack_channel_info", fake_get_slack_channel_info)
|
monkeypatch.setattr(webhook_common, "get_slack_channel_info", fake_get_slack_channel_info)
|
||||||
|
|
||||||
assert asyncio.run(webapp._is_docs_plz_slack_channel("C_DOCS")) is True
|
assert asyncio.run(webhook_common._is_docs_plz_slack_channel("C_DOCS")) is True
|
||||||
|
|
||||||
|
|
||||||
def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
|
def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
|
||||||
|
|
@ -606,16 +621,18 @@ def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
|
||||||
) -> None:
|
) -> None:
|
||||||
raise AssertionError("docs-plz gate should not start the agent")
|
raise AssertionError("docs-plz gate should not start the agent")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
||||||
monkeypatch.setattr(webapp, "_get_slack_channel_context", fake_get_slack_channel_context)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
webhook_common, "_get_slack_channel_context", fake_get_slack_channel_context
|
||||||
monkeypatch.setattr(webapp, "get_slack_repo_config", fail_get_slack_repo_config)
|
)
|
||||||
monkeypatch.setattr(webapp, "process_slack_mention", fail_process_slack_mention)
|
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", fake_post_slack_thread_reply)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_slack_repo_config", fail_get_slack_repo_config)
|
||||||
|
monkeypatch.setattr(slack_webhooks, "process_slack_mention", fail_process_slack_mention)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_slack_webhook(
|
response = _post_slack_webhook(
|
||||||
client,
|
client,
|
||||||
{
|
{
|
||||||
|
|
@ -636,7 +653,7 @@ def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
|
||||||
assert captured["reply"] == {
|
assert captured["reply"] == {
|
||||||
"channel_id": "C_DOCS",
|
"channel_id": "C_DOCS",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
"text": webapp.DOCS_PLZ_SLACK_GATE_REPLY,
|
"text": webhook_common.DOCS_PLZ_SLACK_GATE_REPLY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -676,15 +693,17 @@ def test_slack_webhook_routes_review_command_to_agent(monkeypatch) -> None:
|
||||||
captured["event_data"] = event_data
|
captured["event_data"] = event_data
|
||||||
captured["repo_config"] = repo_config
|
captured["repo_config"] = repo_config
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
||||||
monkeypatch.setattr(webapp, "_get_slack_channel_context", fake_get_slack_channel_context)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
|
webhook_common, "_get_slack_channel_context", fake_get_slack_channel_context
|
||||||
monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention)
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_slack_repo_config", fake_get_slack_repo_config)
|
||||||
|
monkeypatch.setattr(slack_webhooks, "process_slack_mention", fake_process_slack_mention)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_slack_webhook(
|
response = _post_slack_webhook(
|
||||||
client,
|
client,
|
||||||
{
|
{
|
||||||
|
|
@ -729,14 +748,14 @@ def test_slack_webhook_malformed_review_command_starts_agent(monkeypatch) -> Non
|
||||||
captured["event_data"] = event_data
|
captured["event_data"] = event_data
|
||||||
captured["repo_config"] = repo_config
|
captured["repo_config"] = repo_config
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
||||||
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
|
monkeypatch.setattr(webhook_common, "get_slack_repo_config", fake_get_slack_repo_config)
|
||||||
monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention)
|
monkeypatch.setattr(slack_webhooks, "process_slack_mention", fake_process_slack_mention)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_slack_webhook(
|
response = _post_slack_webhook(
|
||||||
client,
|
client,
|
||||||
{
|
{
|
||||||
|
|
@ -780,21 +799,21 @@ def test_slack_webhook_non_pr_review_request_starts_agent(monkeypatch) -> None:
|
||||||
captured["event_data"] = event_data
|
captured["event_data"] = event_data
|
||||||
captured["repo_config"] = repo_config
|
captured["repo_config"] = repo_config
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
||||||
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
|
monkeypatch.setattr(webhook_common, "get_slack_repo_config", fake_get_slack_repo_config)
|
||||||
monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention)
|
monkeypatch.setattr(slack_webhooks, "process_slack_mention", fake_process_slack_mention)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"_is_repo_allowed",
|
"_is_repo_allowed",
|
||||||
lambda repo_config: (_ for _ in ()).throw(
|
lambda repo_config: (_ for _ in ()).throw(
|
||||||
AssertionError("Slack webhook should not gate inferred repos with allowlists")
|
AssertionError("Slack webhook should not gate inferred repos with allowlists")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_slack_webhook(
|
response = _post_slack_webhook(
|
||||||
client,
|
client,
|
||||||
{
|
{
|
||||||
|
|
@ -836,14 +855,14 @@ def test_slack_webhook_threaded_followup_uses_parent_thread_ts(monkeypatch) -> N
|
||||||
captured["event_data"] = event_data
|
captured["event_data"] = event_data
|
||||||
captured["repo_config"] = repo_config
|
captured["repo_config"] = repo_config
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
|
||||||
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
|
monkeypatch.setattr(webhook_common, "get_slack_repo_config", fake_get_slack_repo_config)
|
||||||
monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention)
|
monkeypatch.setattr(slack_webhooks, "process_slack_mention", fake_process_slack_mention)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_slack_webhook(
|
response = _post_slack_webhook(
|
||||||
client,
|
client,
|
||||||
{
|
{
|
||||||
|
|
@ -904,7 +923,7 @@ def test_process_github_pr_ready_creates_reviewer_run(monkeypatch) -> None:
|
||||||
captured["set_metadata_kwargs"] = kwargs
|
captured["set_metadata_kwargs"] = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_github_app_installation_token_with_expiry",
|
"get_github_app_installation_token_with_expiry",
|
||||||
fake_get_github_app_installation_token_with_expiry,
|
fake_get_github_app_installation_token_with_expiry,
|
||||||
)
|
)
|
||||||
|
|
@ -913,13 +932,17 @@ def test_process_github_pr_ready_creates_reviewer_run(monkeypatch) -> None:
|
||||||
captured["status_comment_kwargs"] = kwargs
|
captured["status_comment_kwargs"] = kwargs
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_github_token)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
|
webhook_common, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webhook_common, "post_review_started_comment", fake_post_review_started_comment
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_pr_ready(
|
github_webhooks.process_github_pr_ready(
|
||||||
{
|
{
|
||||||
"action": "opened",
|
"action": "opened",
|
||||||
"pull_request": {
|
"pull_request": {
|
||||||
|
|
@ -1002,12 +1025,12 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
captured["set_metadata_thread_id"] = thread_id
|
captured["set_metadata_thread_id"] = thread_id
|
||||||
captured["set_metadata_kwargs"] = kwargs
|
captured["set_metadata_kwargs"] = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
monkeypatch.setattr(webhook_common, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webhook_common, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_github_app_installation_token_with_expiry",
|
"get_github_app_installation_token_with_expiry",
|
||||||
fake_get_github_app_installation_token_with_expiry,
|
fake_get_github_app_installation_token_with_expiry,
|
||||||
)
|
)
|
||||||
|
|
@ -1016,14 +1039,18 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
captured["status_comment_kwargs"] = kwargs
|
captured["status_comment_kwargs"] = kwargs
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
|
monkeypatch.setattr(webhook_common, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_github_token)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
|
webhook_common, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webhook_common, "post_review_started_comment", fake_post_review_started_comment
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
webapp.trigger_pr_review_from_ref(
|
github_webhooks.trigger_pr_review_from_ref(
|
||||||
GitHubPrRef(
|
GitHubPrRef(
|
||||||
owner="langchain-ai",
|
owner="langchain-ai",
|
||||||
repo="open-swe",
|
repo="open-swe",
|
||||||
|
|
@ -1140,14 +1167,16 @@ def test_process_github_pr_comment_without_email_skips(
|
||||||
async def fake_trigger_or_queue_run(*args, **kwargs) -> None:
|
async def fake_trigger_or_queue_run(*args, **kwargs) -> None:
|
||||||
captured["triggered"] = {"args": args, "kwargs": kwargs}
|
captured["triggered"] = {"args": args, "kwargs": kwargs}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context)
|
monkeypatch.setattr(webhook_common, "extract_pr_context", fake_extract_pr_context)
|
||||||
monkeypatch.setattr(webapp, "email_for_login", lambda login: asyncio.sleep(0, result=None))
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
|
webhook_common, "email_for_login", lambda login: asyncio.sleep(0, result=None)
|
||||||
monkeypatch.setattr(webapp, "fetch_pr_comments_since_last_tag", fake_fetch_comments)
|
)
|
||||||
monkeypatch.setattr(webapp, "_trigger_or_queue_run", fake_trigger_or_queue_run)
|
monkeypatch.setattr(webhook_common, "react_to_github_comment", fake_react)
|
||||||
|
monkeypatch.setattr(webhook_common, "fetch_pr_comments_since_last_tag", fake_fetch_comments)
|
||||||
|
monkeypatch.setattr(webhook_common, "_trigger_or_queue_run", fake_trigger_or_queue_run)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_pr_comment(
|
github_webhooks.process_github_pr_comment(
|
||||||
{
|
{
|
||||||
"comment": {"id": 9, "body": "@open-swe review"},
|
"comment": {"id": 9, "body": "@open-swe review"},
|
||||||
"sender": {"login": "external-user", "id": 123},
|
"sender": {"login": "external-user", "id": 123},
|
||||||
|
|
@ -1199,17 +1228,21 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "_get_or_resolve_thread_github_token", fake_get_or_resolve_thread_github_token
|
webhook_common,
|
||||||
|
"_get_or_resolve_thread_github_token",
|
||||||
|
fake_get_or_resolve_thread_github_token,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webhook_common, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", lambda thread_id: asyncio.sleep(0, result=False))
|
|
||||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react_to_github_comment)
|
|
||||||
monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments)
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common, "_thread_exists", lambda thread_id: asyncio.sleep(0, result=False)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "react_to_github_comment", fake_react_to_github_comment)
|
||||||
|
monkeypatch.setattr(webhook_common, "fetch_issue_comments", fake_fetch_issue_comments)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webhook_common,
|
||||||
"email_for_login",
|
"email_for_login",
|
||||||
lambda login: asyncio.sleep(
|
lambda login: asyncio.sleep(
|
||||||
0, result="octocat@example.com" if login == "octocat" else None
|
0, result="octocat@example.com" if login == "octocat" else None
|
||||||
|
|
@ -1217,7 +1250,7 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_issue(
|
github_webhooks.process_github_issue(
|
||||||
{
|
{
|
||||||
"issue": {
|
"issue": {
|
||||||
"id": 12345,
|
"id": 12345,
|
||||||
|
|
@ -1278,17 +1311,19 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "_get_or_resolve_thread_github_token", fake_get_or_resolve_thread_github_token
|
webhook_common,
|
||||||
|
"_get_or_resolve_thread_github_token",
|
||||||
|
fake_get_or_resolve_thread_github_token,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webhook_common, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react_to_github_comment)
|
monkeypatch.setattr(webhook_common, "react_to_github_comment", fake_react_to_github_comment)
|
||||||
monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments)
|
monkeypatch.setattr(webhook_common, "fetch_issue_comments", fake_fetch_issue_comments)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"email_for_login",
|
"email_for_login",
|
||||||
lambda login: asyncio.sleep(
|
lambda login: asyncio.sleep(
|
||||||
0, result="octocat@example.com" if login == "octocat" else None
|
0, result="octocat@example.com" if login == "octocat" else None
|
||||||
|
|
@ -1300,7 +1335,7 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_issue(
|
github_webhooks.process_github_issue(
|
||||||
{
|
{
|
||||||
"issue": {
|
"issue": {
|
||||||
"id": 12345,
|
"id": 12345,
|
||||||
|
|
@ -1332,11 +1367,11 @@ def test_github_webhook_routes_pr_comment_review_to_agent(monkeypatch) -> None:
|
||||||
captured["payload"] = payload
|
captured["payload"] = payload
|
||||||
captured["event_type"] = event_type
|
captured["event_type"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment)
|
monkeypatch.setattr(github_webhooks, "process_github_pr_comment", fake_process_pr_comment)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -1365,11 +1400,11 @@ def test_github_webhook_routes_pr_review_request_comment_to_agent(monkeypatch) -
|
||||||
captured["payload"] = payload
|
captured["payload"] = payload
|
||||||
captured["event_type"] = event_type
|
captured["event_type"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment)
|
monkeypatch.setattr(github_webhooks, "process_github_pr_comment", fake_process_pr_comment)
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Covers:
|
Covers:
|
||||||
- (a) expired-cache reads return None / fall through to re-auth
|
- (a) expired-cache reads return None / fall through to re-auth
|
||||||
- (b) 401 on a downstream GitHub call invalidates the cached token and
|
- (b) 401 on a downstream GitHub call invalidates the cached token and
|
||||||
triggers a fresh resolve in the webapp
|
triggers a fresh resolve in the webhook layer
|
||||||
- (c) ``publish_review`` invalidates the cached token and returns a clean
|
- (c) ``publish_review`` invalidates the cached token and returns a clean
|
||||||
failure when GitHub responds 401
|
failure when GitHub responds 401
|
||||||
"""
|
"""
|
||||||
|
|
@ -198,7 +198,8 @@ def test_process_github_pr_comment_invalidates_and_reauths_on_401(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""End-to-end check: a 401 on react triggers invalidate + re-resolve."""
|
"""End-to-end check: a 401 on react triggers invalidate + re-resolve."""
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
|
||||||
invalidated: dict[str, int] = {"calls": 0}
|
invalidated: dict[str, int] = {"calls": 0}
|
||||||
resolves: list[str] = []
|
resolves: list[str] = []
|
||||||
|
|
@ -253,20 +254,20 @@ def test_process_github_pr_comment_invalidates_and_reauths_on_401(
|
||||||
async def fake_trigger_or_queue_run(*args: Any, **kwargs: Any) -> None:
|
async def fake_trigger_or_queue_run(*args: Any, **kwargs: Any) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context)
|
monkeypatch.setattr(webhook_common, "extract_pr_context", fake_extract_pr_context)
|
||||||
monkeypatch.setattr(webapp, "_get_or_resolve_thread_github_token", fake_get_or_resolve)
|
monkeypatch.setattr(webhook_common, "_get_or_resolve_thread_github_token", fake_get_or_resolve)
|
||||||
monkeypatch.setattr(webapp, "invalidate_cached_github_token", fake_invalidate)
|
monkeypatch.setattr(webhook_common, "invalidate_cached_github_token", fake_invalidate)
|
||||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
|
monkeypatch.setattr(webhook_common, "react_to_github_comment", fake_react)
|
||||||
monkeypatch.setattr(webapp, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments)
|
monkeypatch.setattr(webhook_common, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments)
|
||||||
monkeypatch.setattr(webapp, "_trigger_or_queue_run", fake_trigger_or_queue_run)
|
monkeypatch.setattr(webhook_common, "_trigger_or_queue_run", fake_trigger_or_queue_run)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"email_for_login",
|
"email_for_login",
|
||||||
lambda login: asyncio.sleep(0, result="octo@example.com" if login == "octo" else None),
|
lambda login: asyncio.sleep(0, result="octo@example.com" if login == "octo" else None),
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_github_pr_comment(
|
github_webhooks.process_github_pr_comment(
|
||||||
{"sender": {"login": "octo", "id": 1}},
|
{"sender": {"login": "octo", "id": 1}},
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
from agent.webhooks import jira as jira_webhook
|
from agent.webhooks import jira as jira_webhook
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ def _full_issue(*, title: str = "Fix the flaky test") -> dict:
|
||||||
|
|
||||||
def _issue_data(*, account_id: str | None, email: str | None, name: str = "Jane") -> dict:
|
def _issue_data(*, account_id: str | None, email: str | None, name: str = "Jane") -> dict:
|
||||||
# jira_webhook resolves account_id -> email once and attaches it to
|
# jira_webhook resolves account_id -> email once and attaches it to
|
||||||
# comment_author before dispatch (see webapp.jira_webhook).
|
# comment_author before dispatch (see jira_routes.jira_webhook).
|
||||||
return {
|
return {
|
||||||
"key": "PROJ-42",
|
"key": "PROJ-42",
|
||||||
"project_key": "PROJ",
|
"project_key": "PROJ",
|
||||||
|
|
@ -67,31 +67,39 @@ def _run_process(
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp, "generate_thread_id_from_jira_issue", return_value="thread-1"
|
webhook_common,
|
||||||
|
"generate_thread_id_from_jira_issue",
|
||||||
|
return_value="thread-1",
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp,
|
webhook_common,
|
||||||
"fetch_jira_issue_details",
|
"fetch_jira_issue_details",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=_full_issue(),
|
return_value=_full_issue(),
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp,
|
webhook_common,
|
||||||
"fetch_jira_issue_comments",
|
"fetch_jira_issue_comments",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp, "resolve_login_from_email_async", side_effect=fake_resolve_login
|
webhook_common,
|
||||||
|
"resolve_login_from_email_async",
|
||||||
|
side_effect=fake_resolve_login,
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp, "is_login_mapped", side_effect=lambda login: login in active
|
webhook_common,
|
||||||
|
"is_login_mapped",
|
||||||
|
side_effect=lambda login: login in active,
|
||||||
),
|
),
|
||||||
patch.object(jira_webhook.webapp, "dispatch_agent_run", side_effect=fake_dispatch),
|
patch.object(webhook_common, "dispatch_agent_run", side_effect=fake_dispatch),
|
||||||
patch.object(
|
patch.object(
|
||||||
jira_webhook.webapp, "upsert_agent_thread_owner_metadata", side_effect=fake_upsert
|
webhook_common,
|
||||||
|
"upsert_agent_thread_owner_metadata",
|
||||||
|
side_effect=fake_upsert,
|
||||||
),
|
),
|
||||||
patch.object(jira_webhook.webapp, "post_jira_trace_comment", new_callable=AsyncMock),
|
patch.object(webhook_common, "post_jira_trace_comment", new_callable=AsyncMock),
|
||||||
):
|
):
|
||||||
asyncio.run(jira_webhook.process_jira_issue(issue_data, repo_config))
|
asyncio.run(jira_webhook.process_jira_issue(issue_data, repo_config))
|
||||||
|
|
||||||
|
|
@ -164,22 +172,25 @@ def test_jira_omits_login_when_no_email_resolved() -> None:
|
||||||
|
|
||||||
def test_repo_cascade_uses_project_mapping(monkeypatch) -> None:
|
def test_repo_cascade_uses_project_mapping(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "JIRA_PROJECT_TO_REPO", {"PROJ": {"owner": "acme", "name": "widgets"}}
|
webhook_common, "JIRA_PROJECT_TO_REPO", {"PROJ": {"owner": "acme", "name": "widgets"}}
|
||||||
)
|
)
|
||||||
assert webapp.get_repo_config_from_jira_mapping("PROJ") == {"owner": "acme", "name": "widgets"}
|
assert webhook_common.get_repo_config_from_jira_mapping("PROJ") == {
|
||||||
|
"owner": "acme",
|
||||||
|
"name": "widgets",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_repo_cascade_falls_back_to_default_repo(monkeypatch) -> None:
|
def test_repo_cascade_falls_back_to_default_repo(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_PROJECT_TO_REPO", {})
|
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
|
||||||
monkeypatch.setattr(webapp, "DEFAULT_REPO_OWNER", "langchain-ai")
|
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_OWNER", "langchain-ai")
|
||||||
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "open-swe")
|
||||||
assert webapp.get_repo_config_from_jira_mapping("UNKNOWN") == {
|
assert webhook_common.get_repo_config_from_jira_mapping("UNKNOWN") == {
|
||||||
"owner": "langchain-ai",
|
"owner": "langchain-ai",
|
||||||
"name": "open-swe",
|
"name": "open-swe",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_repo_cascade_empty_without_default(monkeypatch) -> None:
|
def test_repo_cascade_empty_without_default(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_PROJECT_TO_REPO", {})
|
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
|
||||||
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
|
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "")
|
||||||
assert webapp.get_repo_config_from_jira_mapping("UNKNOWN") == {}
|
assert webhook_common.get_repo_config_from_jira_mapping("UNKNOWN") == {}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ from contextlib import ExitStack
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import jira_routes
|
||||||
|
|
||||||
|
|
||||||
class _FakeRequest:
|
class _FakeRequest:
|
||||||
|
|
@ -45,26 +46,32 @@ def _call(
|
||||||
bg = _FakeBackgroundTasks()
|
bg = _FakeBackgroundTasks()
|
||||||
get_email = AsyncMock(return_value=email)
|
get_email = AsyncMock(return_value=email)
|
||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(patch.object(webapp, "verify_jira_secret", return_value=True))
|
stack.enter_context(patch.object(webhook_common, "verify_jira_secret", return_value=True))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(webapp, "fetch_jira_comment", new=AsyncMock(return_value=server_comment))
|
patch.object(
|
||||||
|
webhook_common, "fetch_jira_comment", new=AsyncMock(return_value=server_comment)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
stack.enter_context(patch.object(webapp, "get_jira_user_email", new=get_email))
|
stack.enter_context(patch.object(webhook_common, "get_jira_user_email", new=get_email))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(webapp, "resolve_login_from_email_async", new=AsyncMock(return_value=None))
|
patch.object(
|
||||||
)
|
webhook_common, "resolve_login_from_email_async", new=AsyncMock(return_value=None)
|
||||||
stack.enter_context(
|
)
|
||||||
patch.object(webapp, "get_profile_default_repo", new=AsyncMock(return_value=None))
|
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
webapp,
|
webhook_common, "get_profile_default_repo", new=AsyncMock(return_value=None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stack.enter_context(
|
||||||
|
patch.object(
|
||||||
|
webhook_common,
|
||||||
"get_repo_config_from_jira_mapping",
|
"get_repo_config_from_jira_mapping",
|
||||||
return_value={"owner": "langchain-ai", "name": "open-swe"},
|
return_value={"owner": "langchain-ai", "name": "open-swe"},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stack.enter_context(patch.object(webapp, "_is_repo_allowed", return_value=True))
|
stack.enter_context(patch.object(webhook_common, "_is_repo_allowed", return_value=True))
|
||||||
result = asyncio.run(webapp.jira_webhook(req, bg))
|
result = asyncio.run(jira_routes.jira_webhook(req, bg))
|
||||||
return result, bg, get_email
|
return result, bg, get_email
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
_SECRET = "jira-automation-secret"
|
_SECRET = "jira-automation-secret"
|
||||||
|
|
||||||
|
|
@ -18,74 +18,74 @@ _SECRET = "jira-automation-secret"
|
||||||
def _signed_body(secret: str, *, fresh: bool = True) -> tuple[bytes, str]:
|
def _signed_body(secret: str, *, fresh: bool = True) -> tuple[bytes, str]:
|
||||||
ts_ms = datetime.now(UTC).timestamp() * 1000
|
ts_ms = datetime.now(UTC).timestamp() * 1000
|
||||||
if not fresh:
|
if not fresh:
|
||||||
ts_ms -= (webapp.JIRA_WEBHOOK_MAX_AGE_SECONDS + 60) * 1000
|
ts_ms -= (webhook_common.JIRA_WEBHOOK_MAX_AGE_SECONDS + 60) * 1000
|
||||||
body = json.dumps({"issue_key": "PROJ-1", "timestamp": ts_ms}).encode()
|
body = json.dumps({"issue_key": "PROJ-1", "timestamp": ts_ms}).encode()
|
||||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
return body, sig
|
return body, sig
|
||||||
|
|
||||||
|
|
||||||
def test_valid_secret_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_valid_secret_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
headers = {"X-Automation-Webhook-Token": _SECRET}
|
headers = {"X-Automation-Webhook-Token": _SECRET}
|
||||||
assert webapp.verify_jira_secret(headers) is True
|
assert webhook_common.verify_jira_secret(headers) is True
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_secret_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wrong_secret_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
headers = {"X-Automation-Webhook-Token": "wrong-token"}
|
headers = {"X-Automation-Webhook-Token": "wrong-token"}
|
||||||
assert webapp.verify_jira_secret(headers) is False
|
assert webhook_common.verify_jira_secret(headers) is False
|
||||||
|
|
||||||
|
|
||||||
def test_missing_header_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_missing_header_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
assert webapp.verify_jira_secret({}) is False
|
assert webhook_common.verify_jira_secret({}) is False
|
||||||
|
|
||||||
|
|
||||||
def test_empty_header_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_empty_header_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
assert webapp.verify_jira_secret({"X-Automation-Webhook-Token": ""}) is False
|
assert webhook_common.verify_jira_secret({"X-Automation-Webhook-Token": ""}) is False
|
||||||
|
|
||||||
|
|
||||||
def test_unset_env_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_unset_env_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", "")
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", "")
|
||||||
headers = {"X-Automation-Webhook-Token": _SECRET}
|
headers = {"X-Automation-Webhook-Token": _SECRET}
|
||||||
assert webapp.verify_jira_secret(headers) is False
|
assert webhook_common.verify_jira_secret(headers) is False
|
||||||
|
|
||||||
|
|
||||||
# --- Opt-in HMAC body signature + timestamp (JIRA_WEBHOOK_REQUIRE_SIGNATURE) ---
|
# --- Opt-in HMAC body signature + timestamp (JIRA_WEBHOOK_REQUIRE_SIGNATURE) ---
|
||||||
|
|
||||||
|
|
||||||
def test_signature_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_signature_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", False)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", False)
|
||||||
assert webapp.verify_jira_signature(b"{}", {}) is True
|
assert webhook_common.verify_jira_signature(b"{}", {}) is True
|
||||||
|
|
||||||
|
|
||||||
def test_valid_signature_and_fresh_timestamp_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_valid_signature_and_fresh_timestamp_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
body, sig = _signed_body(_SECRET)
|
body, sig = _signed_body(_SECRET)
|
||||||
assert webapp.verify_jira_signature(body, {"X-Openswe-Signature": sig}) is True
|
assert webhook_common.verify_jira_signature(body, {"X-Openswe-Signature": sig}) is True
|
||||||
|
|
||||||
|
|
||||||
def test_missing_signature_rejected_when_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_missing_signature_rejected_when_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
body, _sig = _signed_body(_SECRET)
|
body, _sig = _signed_body(_SECRET)
|
||||||
assert webapp.verify_jira_signature(body, {}) is False
|
assert webhook_common.verify_jira_signature(body, {}) is False
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_signature_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wrong_signature_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
body, _sig = _signed_body(_SECRET)
|
body, _sig = _signed_body(_SECRET)
|
||||||
assert webapp.verify_jira_signature(body, {"X-Openswe-Signature": "deadbeef"}) is False
|
assert webhook_common.verify_jira_signature(body, {"X-Openswe-Signature": "deadbeef"}) is False
|
||||||
|
|
||||||
|
|
||||||
def test_stale_timestamp_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_stale_timestamp_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
|
||||||
body, sig = _signed_body(_SECRET, fresh=False)
|
body, sig = _signed_body(_SECRET, fresh=False)
|
||||||
assert webapp.verify_jira_signature(body, {"X-Openswe-Signature": sig}) is False
|
assert webhook_common.verify_jira_signature(body, {"X-Openswe-Signature": sig}) is False
|
||||||
|
|
||||||
|
|
||||||
# --- Opt-in source-IP allowlist (JIRA_WEBHOOK_IP_ALLOWLIST) ---
|
# --- Opt-in source-IP allowlist (JIRA_WEBHOOK_IP_ALLOWLIST) ---
|
||||||
|
|
@ -97,37 +97,37 @@ def _req(host: str | None) -> object:
|
||||||
|
|
||||||
|
|
||||||
def test_ip_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ip_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ())
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ())
|
||||||
assert webapp.verify_jira_source_ip(_req("9.9.9.9")) is True
|
assert webhook_common.verify_jira_source_ip(_req("9.9.9.9")) is True
|
||||||
|
|
||||||
|
|
||||||
def test_ip_in_allowlist_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ip_in_allowlist_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
||||||
assert webapp.verify_jira_source_ip(_req("10.0.0.5")) is True
|
assert webhook_common.verify_jira_source_ip(_req("10.0.0.5")) is True
|
||||||
|
|
||||||
|
|
||||||
def test_ip_not_in_allowlist_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ip_not_in_allowlist_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
||||||
assert webapp.verify_jira_source_ip(_req("192.168.1.1")) is False
|
assert webhook_common.verify_jira_source_ip(_req("192.168.1.1")) is False
|
||||||
|
|
||||||
|
|
||||||
def test_ip_missing_client_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_ip_missing_client_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
|
||||||
assert webapp.verify_jira_source_ip(_req(None)) is False
|
assert webhook_common.verify_jira_source_ip(_req(None)) is False
|
||||||
|
|
||||||
|
|
||||||
# --- Fail-closed repo allowlist (REQUIRE_REPO_ALLOWLIST) ---
|
# --- Fail-closed repo allowlist (REQUIRE_REPO_ALLOWLIST) ---
|
||||||
|
|
||||||
|
|
||||||
def test_empty_allowlist_allows_all_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_empty_allowlist_allows_all_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_REPOS", frozenset())
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_REPOS", frozenset())
|
||||||
monkeypatch.setattr(webapp, "REQUIRE_REPO_ALLOWLIST", False)
|
monkeypatch.setattr(webhook_common, "REQUIRE_REPO_ALLOWLIST", False)
|
||||||
assert webapp._is_repo_allowed({"owner": "anyone", "name": "anything"}) is True
|
assert webhook_common._is_repo_allowed({"owner": "anyone", "name": "anything"}) is True
|
||||||
|
|
||||||
|
|
||||||
def test_empty_allowlist_fails_closed_when_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_empty_allowlist_fails_closed_when_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_REPOS", frozenset())
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_REPOS", frozenset())
|
||||||
monkeypatch.setattr(webapp, "REQUIRE_REPO_ALLOWLIST", True)
|
monkeypatch.setattr(webhook_common, "REQUIRE_REPO_ALLOWLIST", True)
|
||||||
assert webapp._is_repo_allowed({"owner": "anyone", "name": "anything"}) is False
|
assert webhook_common._is_repo_allowed({"owner": "anyone", "name": "anything"}) is False
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
from agent.webhooks import linear as linear_webhook
|
from agent.webhooks import linear as linear_webhook
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,24 +59,26 @@ def _run_process(
|
||||||
return "zhen" if email == "zhen@example.com" else None
|
return "zhen" if email == "zhen@example.com" else None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(linear_webhook.webapp, "react_to_linear_comment", new_callable=AsyncMock),
|
patch.object(webhook_common, "react_to_linear_comment", new_callable=AsyncMock),
|
||||||
|
patch.object(webhook_common, "generate_thread_id_from_issue", return_value="thread-1"),
|
||||||
patch.object(
|
patch.object(
|
||||||
linear_webhook.webapp, "generate_thread_id_from_issue", return_value="thread-1"
|
webhook_common,
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
linear_webhook.webapp,
|
|
||||||
"fetch_linear_issue_details",
|
"fetch_linear_issue_details",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=_full_issue(user_email=issue_data.get("comment_author", {}).get("email")),
|
return_value=_full_issue(user_email=issue_data.get("comment_author", {}).get("email")),
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
linear_webhook.webapp, "resolve_login_from_email_async", side_effect=fake_resolve_login
|
webhook_common,
|
||||||
|
"resolve_login_from_email_async",
|
||||||
|
side_effect=fake_resolve_login,
|
||||||
),
|
),
|
||||||
patch.object(linear_webhook.webapp, "dispatch_agent_run", side_effect=fake_dispatch),
|
patch.object(webhook_common, "dispatch_agent_run", side_effect=fake_dispatch),
|
||||||
patch.object(
|
patch.object(
|
||||||
linear_webhook.webapp, "upsert_agent_thread_owner_metadata", side_effect=fake_upsert
|
webhook_common,
|
||||||
|
"upsert_agent_thread_owner_metadata",
|
||||||
|
side_effect=fake_upsert,
|
||||||
),
|
),
|
||||||
patch.object(linear_webhook.webapp, "post_linear_trace_comment", new_callable=AsyncMock),
|
patch.object(webhook_common, "post_linear_trace_comment", new_callable=AsyncMock),
|
||||||
):
|
):
|
||||||
asyncio.run(linear_webhook.process_linear_issue(issue_data, repo_config))
|
asyncio.run(linear_webhook.process_linear_issue(issue_data, repo_config))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import hmac
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
|
||||||
_SECRET = "linear-signing-secret"
|
_SECRET = "linear-signing-secret"
|
||||||
|
|
||||||
|
|
@ -22,32 +22,32 @@ def _now_ms() -> int:
|
||||||
|
|
||||||
def test_fresh_timestamp_accepted() -> None:
|
def test_fresh_timestamp_accepted() -> None:
|
||||||
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
|
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
|
||||||
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is True
|
assert webhook_common.verify_linear_signature(body, _sign(body), _SECRET) is True
|
||||||
|
|
||||||
|
|
||||||
def test_stale_timestamp_rejected() -> None:
|
def test_stale_timestamp_rejected() -> None:
|
||||||
stale = _now_ms() - 10 * 60 * 1000 # 10 minutes old
|
stale = _now_ms() - 10 * 60 * 1000 # 10 minutes old
|
||||||
body = json.dumps({"type": "Comment", "webhookTimestamp": stale}).encode()
|
body = json.dumps({"type": "Comment", "webhookTimestamp": stale}).encode()
|
||||||
# Signature is valid, but the timestamp is outside the freshness window.
|
# Signature is valid, but the timestamp is outside the freshness window.
|
||||||
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
|
assert webhook_common.verify_linear_signature(body, _sign(body), _SECRET) is False
|
||||||
|
|
||||||
|
|
||||||
def test_future_timestamp_rejected() -> None:
|
def test_future_timestamp_rejected() -> None:
|
||||||
future = _now_ms() + 10 * 60 * 1000
|
future = _now_ms() + 10 * 60 * 1000
|
||||||
body = json.dumps({"type": "Comment", "webhookTimestamp": future}).encode()
|
body = json.dumps({"type": "Comment", "webhookTimestamp": future}).encode()
|
||||||
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
|
assert webhook_common.verify_linear_signature(body, _sign(body), _SECRET) is False
|
||||||
|
|
||||||
|
|
||||||
def test_missing_timestamp_rejected() -> None:
|
def test_missing_timestamp_rejected() -> None:
|
||||||
body = json.dumps({"type": "Comment"}).encode()
|
body = json.dumps({"type": "Comment"}).encode()
|
||||||
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
|
assert webhook_common.verify_linear_signature(body, _sign(body), _SECRET) is False
|
||||||
|
|
||||||
|
|
||||||
def test_non_numeric_timestamp_rejected() -> None:
|
def test_non_numeric_timestamp_rejected() -> None:
|
||||||
body = json.dumps({"type": "Comment", "webhookTimestamp": "not-a-number"}).encode()
|
body = json.dumps({"type": "Comment", "webhookTimestamp": "not-a-number"}).encode()
|
||||||
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
|
assert webhook_common.verify_linear_signature(body, _sign(body), _SECRET) is False
|
||||||
|
|
||||||
|
|
||||||
def test_bad_signature_rejected_even_when_fresh() -> None:
|
def test_bad_signature_rejected_even_when_fresh() -> None:
|
||||||
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
|
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
|
||||||
assert webapp.verify_linear_signature(body, "deadbeef", _SECRET) is False
|
assert webhook_common.verify_linear_signature(body, "deadbeef", _SECRET) is False
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ async def test_save_plan_reads_markdown_file_from_sandbox(
|
||||||
|
|
||||||
|
|
||||||
def test_plan_routes_registered() -> None:
|
def test_plan_routes_registered() -> None:
|
||||||
from agent.webapp import app
|
from agent.api.app import app
|
||||||
|
|
||||||
paths = set()
|
paths = set()
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
|
|
@ -746,7 +746,7 @@ async def test_approve_plan_hands_edited_plan_to_agent(
|
||||||
|
|
||||||
|
|
||||||
def test_plan_update_route_registered() -> None:
|
def test_plan_update_route_registered() -> None:
|
||||||
from agent.webapp import app
|
from agent.api.app import app
|
||||||
|
|
||||||
paths = set()
|
paths = set()
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
|
||||||
|
|
||||||
def _pr_payload(
|
def _pr_payload(
|
||||||
|
|
@ -38,14 +39,16 @@ def _pr_payload(
|
||||||
|
|
||||||
def _patch_dispatch_deps(monkeypatch: pytest.MonkeyPatch, fake_client: Any) -> None:
|
def _patch_dispatch_deps(monkeypatch: pytest.MonkeyPatch, fake_client: Any) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_github_app_installation_token_with_expiry",
|
"get_github_app_installation_token_with_expiry",
|
||||||
AsyncMock(return_value=("token", None)),
|
AsyncMock(return_value=("token", None)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True))
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", MagicMock())
|
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
|
)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", MagicMock())
|
||||||
|
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", AsyncMock())
|
||||||
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: fake_client)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -53,10 +56,10 @@ async def test_pr_ready_non_draft_triggers_run(monkeypatch: pytest.MonkeyPatch)
|
||||||
fake_client = MagicMock()
|
fake_client = MagicMock()
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=False))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=False))
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -71,16 +74,20 @@ async def test_pr_ready_public_repo_uses_scoped_reviewer_token(
|
||||||
fake_client = MagicMock()
|
fake_client = MagicMock()
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
get_token = AsyncMock(return_value=("scoped-token", "expires"))
|
get_token = AsyncMock(return_value=("scoped-token", "expires"))
|
||||||
monkeypatch.setattr(webapp, "get_github_app_installation_token_with_expiry", get_token)
|
monkeypatch.setattr(webhook_common, "get_github_app_installation_token_with_expiry", get_token)
|
||||||
monkeypatch.setattr(webapp, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True))
|
monkeypatch.setattr(
|
||||||
|
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
|
||||||
|
)
|
||||||
cache_token = MagicMock()
|
cache_token = MagicMock()
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", cache_token)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", cache_token)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
|
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", AsyncMock())
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: fake_client)
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=False, private=False))
|
await github_webhooks.process_github_pr_ready(
|
||||||
|
_pr_payload(action="opened", draft=False, private=False)
|
||||||
|
)
|
||||||
|
|
||||||
get_token.assert_awaited_once_with(repository_ids=[123])
|
get_token.assert_awaited_once_with(repository_ids=[123])
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -94,15 +101,19 @@ async def test_pr_ready_private_repo_uses_full_reviewer_token(
|
||||||
fake_client = MagicMock()
|
fake_client = MagicMock()
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
get_token = AsyncMock(return_value=("full-token", "expires"))
|
get_token = AsyncMock(return_value=("full-token", "expires"))
|
||||||
monkeypatch.setattr(webapp, "get_github_app_installation_token_with_expiry", get_token)
|
monkeypatch.setattr(webhook_common, "get_github_app_installation_token_with_expiry", get_token)
|
||||||
monkeypatch.setattr(webapp, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True))
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", MagicMock())
|
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
|
)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
|
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", MagicMock())
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", AsyncMock())
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: fake_client)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=False, private=True))
|
await github_webhooks.process_github_pr_ready(
|
||||||
|
_pr_payload(action="opened", draft=False, private=True)
|
||||||
|
)
|
||||||
|
|
||||||
get_token.assert_awaited_once_with()
|
get_token.assert_awaited_once_with()
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -114,11 +125,13 @@ async def test_pr_ready_for_review_triggers_run(monkeypatch: pytest.MonkeyPatch)
|
||||||
fake_client = MagicMock()
|
fake_client = MagicMock()
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(webapp, "_get_thread_metadata_safe", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "_get_thread_metadata_safe", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="ready_for_review", draft=False))
|
await github_webhooks.process_github_pr_ready(
|
||||||
|
_pr_payload(action="ready_for_review", draft=False)
|
||||||
|
)
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
@ -131,10 +144,10 @@ async def test_pr_ready_for_review_skips_when_head_already_reviewed(
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
set_metadata = AsyncMock()
|
set_metadata = AsyncMock()
|
||||||
get_token = AsyncMock(return_value=("token", None))
|
get_token = AsyncMock(return_value=("token", None))
|
||||||
monkeypatch.setattr(webapp, "get_github_app_installation_token_with_expiry", get_token)
|
monkeypatch.setattr(webhook_common, "get_github_app_installation_token_with_expiry", get_token)
|
||||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", set_metadata)
|
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", set_metadata)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"_get_thread_metadata_safe",
|
"_get_thread_metadata_safe",
|
||||||
AsyncMock(
|
AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
|
|
@ -144,11 +157,13 @@ async def test_pr_ready_for_review_skips_when_head_already_reviewed(
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: fake_client)
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="ready_for_review", draft=False))
|
await github_webhooks.process_github_pr_ready(
|
||||||
|
_pr_payload(action="ready_for_review", draft=False)
|
||||||
|
)
|
||||||
|
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
get_token.assert_not_awaited()
|
get_token.assert_not_awaited()
|
||||||
|
|
@ -164,7 +179,7 @@ async def test_pr_ready_for_review_uses_re_review_after_previous_review(
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"_get_thread_metadata_safe",
|
"_get_thread_metadata_safe",
|
||||||
AsyncMock(
|
AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
|
|
@ -174,10 +189,12 @@ async def test_pr_ready_for_review_uses_re_review_after_previous_review(
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
|
monkeypatch.setattr(webhook_common, "get_team_settings", AsyncMock(return_value={}))
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="ready_for_review", draft=False))
|
await github_webhooks.process_github_pr_ready(
|
||||||
|
_pr_payload(action="ready_for_review", draft=False)
|
||||||
|
)
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -188,7 +205,7 @@ async def test_pr_ready_for_review_uses_re_review_after_previous_review(
|
||||||
assert "marked ready for review" in kwargs["input"]["messages"][0]["content"]
|
assert "marked ready for review" in kwargs["input"]["messages"][0]["content"]
|
||||||
head_sha_writes = [
|
head_sha_writes = [
|
||||||
c.kwargs.get("head_sha")
|
c.kwargs.get("head_sha")
|
||||||
for c in webapp.set_reviewer_thread_metadata.await_args_list
|
for c in webhook_common.set_reviewer_thread_metadata.await_args_list
|
||||||
if c.kwargs.get("head_sha") is not None
|
if c.kwargs.get("head_sha") is not None
|
||||||
]
|
]
|
||||||
assert "headsha" in head_sha_writes
|
assert "headsha" in head_sha_writes
|
||||||
|
|
@ -202,15 +219,15 @@ async def test_pr_ready_draft_user_override_off_wins_over_team_on(
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_profile",
|
"get_profile",
|
||||||
AsyncMock(return_value={"login": "alice", "review_draft_prs": False}),
|
AsyncMock(return_value={"login": "alice", "review_draft_prs": False}),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
webhook_common, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
||||||
)
|
)
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
||||||
|
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -223,17 +240,17 @@ async def test_pr_ready_draft_user_override_on_wins_over_team_off(
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_profile",
|
"get_profile",
|
||||||
AsyncMock(return_value={"login": "alice", "review_draft_prs": True}),
|
AsyncMock(return_value={"login": "alice", "review_draft_prs": True}),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_team_settings",
|
"get_team_settings",
|
||||||
AsyncMock(return_value={"review_draft_prs": False}),
|
AsyncMock(return_value={"review_draft_prs": False}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
@ -247,15 +264,15 @@ async def test_pr_ready_draft_user_default_falls_back_to_team_on(
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
# User profile exists but review_draft_prs is None — inherit team default.
|
# User profile exists but review_draft_prs is None — inherit team default.
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_profile",
|
"get_profile",
|
||||||
AsyncMock(return_value={"login": "alice", "review_draft_prs": None}),
|
AsyncMock(return_value={"login": "alice", "review_draft_prs": None}),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
webhook_common, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
||||||
)
|
)
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
@ -268,14 +285,14 @@ async def test_pr_ready_draft_no_profile_falls_back_to_team_off(
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
# External contributor — inherit team default (off).
|
# External contributor — inherit team default (off).
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp,
|
webhook_common,
|
||||||
"get_team_settings",
|
"get_team_settings",
|
||||||
AsyncMock(return_value={"review_draft_prs": False}),
|
AsyncMock(return_value={"review_draft_prs": False}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
||||||
|
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -287,12 +304,12 @@ async def test_pr_ready_draft_no_profile_falls_back_to_team_on(
|
||||||
fake_client = MagicMock()
|
fake_client = MagicMock()
|
||||||
fake_client.runs.create = AsyncMock()
|
fake_client.runs.create = AsyncMock()
|
||||||
_patch_dispatch_deps(monkeypatch, fake_client)
|
_patch_dispatch_deps(monkeypatch, fake_client)
|
||||||
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
|
monkeypatch.setattr(webhook_common, "get_profile", AsyncMock(return_value=None))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
webhook_common, "get_team_settings", AsyncMock(return_value={"review_draft_prs": True})
|
||||||
)
|
)
|
||||||
|
|
||||||
await webapp.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=True))
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
@ -320,23 +337,23 @@ async def test_converted_to_draft_disables_watch_when_drafts_off(
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_profile",
|
"agent.webhooks.common.get_profile",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"login": "alice", "review_draft_prs": False},
|
return_value={"login": "alice", "review_draft_prs": False},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_team_settings",
|
"agent.webhooks.common.get_team_settings",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"review_draft_prs": False},
|
return_value={"review_draft_prs": False},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", side_effect=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", side_effect=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_converted_to_draft_payload())
|
await github_webhooks.process_github_pr_close(_converted_to_draft_payload())
|
||||||
assert captured and captured[0][1]["watch"] is False
|
assert captured and captured[0][1]["watch"] is False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -347,23 +364,23 @@ async def test_converted_to_draft_keeps_watch_when_author_drafts_on(
|
||||||
fake_set = AsyncMock()
|
fake_set = AsyncMock()
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_profile",
|
"agent.webhooks.common.get_profile",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"login": "alice", "review_draft_prs": True},
|
return_value={"login": "alice", "review_draft_prs": True},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_team_settings",
|
"agent.webhooks.common.get_team_settings",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"review_draft_prs": False},
|
return_value={"review_draft_prs": False},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_converted_to_draft_payload())
|
await github_webhooks.process_github_pr_close(_converted_to_draft_payload())
|
||||||
fake_set.assert_not_called()
|
fake_set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,22 +391,22 @@ async def test_converted_to_draft_keeps_watch_when_team_default_drafts_on(
|
||||||
fake_set = AsyncMock()
|
fake_set = AsyncMock()
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
# Author inherits team default — team has drafts on.
|
# Author inherits team default — team has drafts on.
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_profile",
|
"agent.webhooks.common.get_profile",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"login": "alice", "review_draft_prs": None},
|
return_value={"login": "alice", "review_draft_prs": None},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_team_settings",
|
"agent.webhooks.common.get_team_settings",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"review_draft_prs": True},
|
return_value={"review_draft_prs": True},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_converted_to_draft_payload())
|
await github_webhooks.process_github_pr_close(_converted_to_draft_payload())
|
||||||
fake_set.assert_not_called()
|
fake_set.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import json
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from agent import webapp
|
from agent.api import app as api_app
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
|
||||||
_TEST_WEBHOOK_SECRET = "test-secret-for-webhook"
|
_TEST_WEBHOOK_SECRET = "test-secret-for-webhook"
|
||||||
|
|
||||||
|
|
@ -40,14 +42,14 @@ def _install_membership_stub(monkeypatch, members: set[str]) -> dict[str, list[s
|
||||||
seen["calls"].append(username)
|
seen["calls"].append(username)
|
||||||
return username in members
|
return username in members
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "is_user_active_org_member", fake_is_user_active_org_member)
|
monkeypatch.setattr(webhook_common, "is_user_active_org_member", fake_is_user_active_org_member)
|
||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def _common_setup(monkeypatch, *, gate: str = "langchain-ai") -> None:
|
def _common_setup(monkeypatch, *, gate: str = "langchain-ai") -> None:
|
||||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||||
monkeypatch.setattr(webapp, "PUBLIC_REPO_ORG_GATE", gate)
|
monkeypatch.setattr(webhook_common, "PUBLIC_REPO_ORG_GATE", gate)
|
||||||
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
|
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
|
||||||
|
|
||||||
|
|
||||||
def test_gate_blocks_non_member_on_public_pr_comment(monkeypatch) -> None:
|
def test_gate_blocks_non_member_on_public_pr_comment(monkeypatch) -> None:
|
||||||
|
|
@ -57,9 +59,11 @@ def test_gate_blocks_non_member_on_public_pr_comment(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(*_args, **_kwargs) -> None:
|
async def fake_process_github_pr_comment(*_args, **_kwargs) -> None:
|
||||||
raise AssertionError("should not be called")
|
raise AssertionError("should not be called")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -99,9 +103,11 @@ def test_gate_allows_org_member_on_public_pr_comment(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
||||||
called["event"] = event_type
|
called["event"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -139,9 +145,11 @@ def test_gate_skipped_on_private_repo(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
||||||
called["event"] = event_type
|
called["event"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -180,9 +188,11 @@ def test_gate_disabled_when_env_unset(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
||||||
called["event"] = event_type
|
called["event"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
@ -219,9 +229,9 @@ def test_gate_blocks_non_member_on_public_issue(monkeypatch) -> None:
|
||||||
async def fake_process_github_issue(*_args, **_kwargs) -> None:
|
async def fake_process_github_issue(*_args, **_kwargs) -> None:
|
||||||
raise AssertionError("should not be called")
|
raise AssertionError("should not be called")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issues",
|
"issues",
|
||||||
|
|
@ -252,7 +262,7 @@ def test_review_requested_is_unsupported_before_public_repo_gate(monkeypatch) ->
|
||||||
_common_setup(monkeypatch)
|
_common_setup(monkeypatch)
|
||||||
seen = _install_membership_stub(monkeypatch, members={"insider"})
|
seen = _install_membership_stub(monkeypatch, members={"insider"})
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"pull_request",
|
"pull_request",
|
||||||
|
|
@ -291,9 +301,11 @@ def test_gate_allows_internal_bot_sender(monkeypatch) -> None:
|
||||||
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
async def fake_process_github_pr_comment(payload, event_type) -> None:
|
||||||
called["event"] = event_type
|
called["event"] = event_type
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
|
monkeypatch.setattr(
|
||||||
|
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(webapp.app)
|
client = TestClient(api_app.app)
|
||||||
response = _post_github_webhook(
|
response = _post_github_webhook(
|
||||||
client,
|
client,
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,12 @@ class TestLinearWebhookRepoOverride:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_comment_repo_overrides_team_mapping(self, _base_payload: dict) -> None:
|
async def test_comment_repo_overrides_team_mapping(self, _base_payload: dict) -> None:
|
||||||
from agent.webapp import linear_webhook
|
from agent.webhooks.linear_routes import linear_webhook
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("agent.webapp.verify_linear_signature", return_value=True),
|
patch("agent.webhooks.common.verify_linear_signature", return_value=True),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.fetch_linear_issue_details",
|
"agent.webhooks.common.fetch_linear_issue_details",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={
|
return_value={
|
||||||
"id": "issue-456",
|
"id": "issue-456",
|
||||||
|
|
@ -120,8 +120,8 @@ class TestLinearWebhookRepoOverride:
|
||||||
"comments": {"nodes": []},
|
"comments": {"nodes": []},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
patch("agent.webapp._is_repo_allowed", return_value=True),
|
patch("agent.webhooks.common._is_repo_allowed", return_value=True),
|
||||||
patch("agent.webapp.BackgroundTasks"),
|
patch("agent.webhooks.common.BackgroundTasks"),
|
||||||
):
|
):
|
||||||
mock_request = AsyncMock()
|
mock_request = AsyncMock()
|
||||||
mock_request.body.return_value = json.dumps(_base_payload).encode()
|
mock_request.body.return_value = json.dumps(_base_payload).encode()
|
||||||
|
|
@ -139,7 +139,7 @@ class TestLinearWebhookRepoOverride:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_falls_back_to_team_mapping_when_no_repo_in_comment(self) -> None:
|
async def test_falls_back_to_team_mapping_when_no_repo_in_comment(self) -> None:
|
||||||
from agent.webapp import linear_webhook
|
from agent.webhooks.linear_routes import linear_webhook
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "Comment",
|
"type": "Comment",
|
||||||
|
|
@ -156,9 +156,9 @@ class TestLinearWebhookRepoOverride:
|
||||||
}
|
}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("agent.webapp.verify_linear_signature", return_value=True),
|
patch("agent.webhooks.common.verify_linear_signature", return_value=True),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.fetch_linear_issue_details",
|
"agent.webhooks.common.fetch_linear_issue_details",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={
|
return_value={
|
||||||
"id": "issue-456",
|
"id": "issue-456",
|
||||||
|
|
@ -170,7 +170,7 @@ class TestLinearWebhookRepoOverride:
|
||||||
"comments": {"nodes": []},
|
"comments": {"nodes": []},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
patch("agent.webapp._is_repo_allowed", return_value=True),
|
patch("agent.webhooks.common._is_repo_allowed", return_value=True),
|
||||||
):
|
):
|
||||||
mock_request = AsyncMock()
|
mock_request = AsyncMock()
|
||||||
mock_request.body.return_value = json.dumps(payload).encode()
|
mock_request.body.return_value = json.dumps(payload).encode()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from agent.dashboard.review_api import (
|
||||||
get_pr_head_sha,
|
get_pr_head_sha,
|
||||||
reviewer_thread_id,
|
reviewer_thread_id,
|
||||||
)
|
)
|
||||||
from agent.webapp import generate_reviewer_thread_id
|
from agent.webhooks.common import generate_reviewer_thread_id
|
||||||
|
|
||||||
|
|
||||||
def test_classify_finding():
|
def test_classify_finding():
|
||||||
|
|
@ -147,7 +147,7 @@ async def test_require_image_in_pr_rejects_unreferenced_url(monkeypatch):
|
||||||
await _require_image_in_pr("acme", "repo", 7, "https://x.githubusercontent.com/a.png", "tok")
|
await _require_image_in_pr("acme", "repo", 7, "https://x.githubusercontent.com/a.png", "tok")
|
||||||
|
|
||||||
|
|
||||||
def test_reviewer_thread_id_matches_webapp():
|
def test_reviewer_thread_id_matches_webhook_common():
|
||||||
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)
|
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import github as github_webhooks
|
||||||
|
|
||||||
|
|
||||||
def _push_payload(
|
def _push_payload(
|
||||||
|
|
@ -46,9 +47,11 @@ async def test_push_event_skips_branch_deletion() -> None:
|
||||||
ref="refs/heads/feat-x", after="0000000000000000000000000000000000000000"
|
ref="refs/heads/feat-x", after="0000000000000000000000000000000000000000"
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
# If we got here without crashing and with no other patches needed, the
|
# If we got here without crashing and with no other patches needed, the
|
||||||
# function returned early on the deletion check.
|
# function returned early on the deletion check.
|
||||||
|
|
||||||
|
|
@ -68,26 +71,28 @@ async def test_push_event_skips_when_thread_not_watching() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_github_app_installation_token",
|
"agent.webhooks.common.get_github_app_installation_token",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="t",
|
return_value="t",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_open_pr_for_branch",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=pr,
|
return_value=pr,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": False},
|
return_value={"kind": "reviewer", "watch": False},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,20 +112,22 @@ async def test_push_event_skips_when_pr_diff_unchanged_since_last_review() -> No
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_github_app_installation_token_with_expiry",
|
"agent.webhooks.common.get_github_app_installation_token_with_expiry",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("t", None),
|
return_value=("t", None),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_open_pr_for_branch",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=pr,
|
return_value=pr,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={
|
return_value={
|
||||||
"kind": "reviewer",
|
"kind": "reviewer",
|
||||||
|
|
@ -129,24 +136,24 @@ async def test_push_event_skips_when_pr_diff_unchanged_since_last_review() -> No
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_compare_diff",
|
"agent.webhooks.common._fetch_compare_diff",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=["same diff", "same diff"],
|
side_effect=["same diff", "same diff"],
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new=set_metadata),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new=set_metadata),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.create_review_check_run",
|
"agent.webhooks.common.create_review_check_run",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=42,
|
return_value=42,
|
||||||
) as create_check,
|
) as create_check,
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.complete_review_check_run",
|
"agent.webhooks.common.complete_review_check_run",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=True,
|
return_value=True,
|
||||||
) as complete_check,
|
) as complete_check,
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
|
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
set_metadata.assert_awaited_once()
|
set_metadata.assert_awaited_once()
|
||||||
|
|
@ -175,25 +182,27 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_github_app_installation_token",
|
"agent.webhooks.common.get_github_app_installation_token",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="t",
|
return_value="t",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_github_app_installation_token_with_expiry",
|
"agent.webhooks.common.get_github_app_installation_token_with_expiry",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("t", None),
|
return_value=("t", None),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_open_pr_for_branch",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=pr,
|
return_value=pr,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={
|
return_value={
|
||||||
"kind": "reviewer",
|
"kind": "reviewer",
|
||||||
|
|
@ -202,28 +211,28 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_compare_diff",
|
"agent.webhooks.common._fetch_compare_diff",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=["old diff", "new diff"],
|
side_effect=["old diff", "new diff"],
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._ensure_thread_exists_for_metadata",
|
"agent.webhooks.common._ensure_thread_exists_for_metadata",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch("agent.webapp.cache_github_token_for_thread"),
|
patch("agent.webhooks.common.cache_github_token_for_thread"),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.set_reviewer_thread_metadata",
|
"agent.webhooks.common.set_reviewer_thread_metadata",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
) as set_meta,
|
) as set_meta,
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.create_review_check_run",
|
"agent.webhooks.common.create_review_check_run",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=99,
|
return_value=99,
|
||||||
) as create_check,
|
) as create_check,
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
|
|
||||||
fake_client.runs.create.assert_awaited_once()
|
fake_client.runs.create.assert_awaited_once()
|
||||||
args, kwargs = fake_client.runs.create.await_args
|
args, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -267,20 +276,22 @@ async def test_push_event_idempotent_when_head_unchanged() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.get_github_app_installation_token",
|
"agent.webhooks.common.get_github_app_installation_token",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="t",
|
return_value="t",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_open_pr_for_branch",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=pr,
|
return_value=pr,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={
|
return_value={
|
||||||
"kind": "reviewer",
|
"kind": "reviewer",
|
||||||
|
|
@ -288,17 +299,17 @@ async def test_push_event_idempotent_when_head_unchanged() -> None:
|
||||||
"last_reviewed_sha": "samesha",
|
"last_reviewed_sha": "samesha",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
fake_client.runs.create.assert_not_called()
|
fake_client.runs.create.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reviewer_token_for_repo_public_scopes_by_id() -> None:
|
async def test_reviewer_token_for_repo_public_scopes_by_id() -> None:
|
||||||
get_token = AsyncMock(return_value=("scoped", "exp"))
|
get_token = AsyncMock(return_value=("scoped", "exp"))
|
||||||
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
|
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
|
||||||
token, expires = await webapp._reviewer_token_for_repo(
|
token, expires = await webhook_common._reviewer_token_for_repo(
|
||||||
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=123
|
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=123
|
||||||
)
|
)
|
||||||
assert (token, expires) == ("scoped", "exp")
|
assert (token, expires) == ("scoped", "exp")
|
||||||
|
|
@ -308,8 +319,8 @@ async def test_reviewer_token_for_repo_public_scopes_by_id() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reviewer_token_for_repo_public_scopes_by_name_without_id() -> None:
|
async def test_reviewer_token_for_repo_public_scopes_by_name_without_id() -> None:
|
||||||
get_token = AsyncMock(return_value=("scoped", "exp"))
|
get_token = AsyncMock(return_value=("scoped", "exp"))
|
||||||
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
|
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
|
||||||
await webapp._reviewer_token_for_repo(
|
await webhook_common._reviewer_token_for_repo(
|
||||||
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=None
|
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=None
|
||||||
)
|
)
|
||||||
get_token.assert_awaited_once_with(repositories=["repo"])
|
get_token.assert_awaited_once_with(repositories=["repo"])
|
||||||
|
|
@ -318,8 +329,8 @@ async def test_reviewer_token_for_repo_public_scopes_by_name_without_id() -> Non
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reviewer_token_for_repo_private_uses_full_token() -> None:
|
async def test_reviewer_token_for_repo_private_uses_full_token() -> None:
|
||||||
get_token = AsyncMock(return_value=("full", "exp"))
|
get_token = AsyncMock(return_value=("full", "exp"))
|
||||||
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
|
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
|
||||||
await webapp._reviewer_token_for_repo(
|
await webhook_common._reviewer_token_for_repo(
|
||||||
{"owner": "lc", "name": "repo"}, repo_private=True, repo_id=123
|
{"owner": "lc", "name": "repo"}, repo_private=True, repo_id=123
|
||||||
)
|
)
|
||||||
get_token.assert_awaited_once_with()
|
get_token.assert_awaited_once_with()
|
||||||
|
|
@ -328,8 +339,8 @@ async def test_reviewer_token_for_repo_private_uses_full_token() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reviewer_token_for_repo_unknown_privacy_uses_full_token() -> None:
|
async def test_reviewer_token_for_repo_unknown_privacy_uses_full_token() -> None:
|
||||||
get_token = AsyncMock(return_value=("full", "exp"))
|
get_token = AsyncMock(return_value=("full", "exp"))
|
||||||
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
|
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
|
||||||
await webapp._reviewer_token_for_repo(
|
await webhook_common._reviewer_token_for_repo(
|
||||||
{"owner": "lc", "name": "repo"}, repo_private=None, repo_id=123
|
{"owner": "lc", "name": "repo"}, repo_private=None, repo_id=123
|
||||||
)
|
)
|
||||||
get_token.assert_awaited_once_with()
|
get_token.assert_awaited_once_with()
|
||||||
|
|
@ -352,27 +363,37 @@ async def test_push_event_public_repo_uses_scoped_token() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token),
|
patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token),
|
||||||
patch("agent.webapp._fetch_open_pr_for_branch", new_callable=AsyncMock, return_value=pr),
|
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=pr,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._ensure_thread_exists_for_metadata",
|
"agent.webhooks.common._ensure_thread_exists_for_metadata",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch("agent.webapp.cache_github_token_for_thread", cache_token),
|
patch("agent.webhooks.common.cache_github_token_for_thread", cache_token),
|
||||||
patch("agent.webapp.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
|
patch(
|
||||||
patch("agent.webapp.reconcile_findings_with_review_threads", new_callable=AsyncMock),
|
"agent.webhooks.common.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
),
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch(
|
||||||
|
"agent.webhooks.common.reconcile_findings_with_review_threads", new_callable=AsyncMock
|
||||||
|
),
|
||||||
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
||||||
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
|
|
||||||
get_token.assert_awaited_once_with(repository_ids=[123])
|
get_token.assert_awaited_once_with(repository_ids=[123])
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -396,27 +417,37 @@ async def test_push_event_rescopes_token_when_pr_metadata_reveals_public() -> No
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=True
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token),
|
patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token),
|
||||||
patch("agent.webapp._fetch_open_pr_for_branch", new_callable=AsyncMock, return_value=pr),
|
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._fetch_open_pr_for_branch",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=pr,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._ensure_thread_exists_for_metadata",
|
"agent.webhooks.common._ensure_thread_exists_for_metadata",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch("agent.webapp.cache_github_token_for_thread", cache_token),
|
patch("agent.webhooks.common.cache_github_token_for_thread", cache_token),
|
||||||
patch("agent.webapp.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
|
patch(
|
||||||
patch("agent.webapp.reconcile_findings_with_review_threads", new_callable=AsyncMock),
|
"agent.webhooks.common.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
),
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch(
|
||||||
|
"agent.webhooks.common.reconcile_findings_with_review_threads", new_callable=AsyncMock
|
||||||
|
),
|
||||||
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new_callable=AsyncMock),
|
||||||
|
patch("agent.webhooks.common.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
await webapp.process_github_push_event(payload)
|
await github_webhooks.process_github_push_event(payload)
|
||||||
|
|
||||||
assert get_token.await_args_list == [call(), call(repository_ids=[456])]
|
assert get_token.await_args_list == [call(), call(repository_ids=[456])]
|
||||||
_, kwargs = fake_client.runs.create.await_args
|
_, kwargs = fake_client.runs.create.await_args
|
||||||
|
|
@ -432,16 +463,18 @@ async def test_pr_close_disables_watch() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._is_repo_auto_review_enabled", new_callable=AsyncMock, return_value=False
|
"agent.webhooks.common._is_repo_auto_review_enabled",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=False,
|
||||||
) as auto_review_enabled,
|
) as auto_review_enabled,
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": True},
|
return_value={"kind": "reviewer", "watch": True},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", side_effect=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", side_effect=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_pr_close_payload(action="closed"))
|
await github_webhooks.process_github_pr_close(_pr_close_payload(action="closed"))
|
||||||
auto_review_enabled.assert_not_awaited()
|
auto_review_enabled.assert_not_awaited()
|
||||||
assert captured and captured[0][1]["watch"] is False
|
assert captured and captured[0][1]["watch"] is False
|
||||||
|
|
||||||
|
|
@ -455,13 +488,13 @@ async def test_pr_reopened_re_enables_watch() -> None:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "reviewer", "watch": False},
|
return_value={"kind": "reviewer", "watch": False},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", side_effect=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", side_effect=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_pr_close_payload(action="reopened"))
|
await github_webhooks.process_github_pr_close(_pr_close_payload(action="reopened"))
|
||||||
assert captured and captured[0][1]["watch"] is True
|
assert captured and captured[0][1]["watch"] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -470,11 +503,11 @@ async def test_pr_close_skips_non_reviewer_threads() -> None:
|
||||||
fake_set = AsyncMock()
|
fake_set = AsyncMock()
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._get_thread_metadata_safe",
|
"agent.webhooks.common._get_thread_metadata_safe",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"kind": "agent"},
|
return_value={"kind": "agent"},
|
||||||
),
|
),
|
||||||
patch("agent.webapp.set_reviewer_thread_metadata", new=fake_set),
|
patch("agent.webhooks.common.set_reviewer_thread_metadata", new=fake_set),
|
||||||
):
|
):
|
||||||
await webapp.process_github_pr_close(_pr_close_payload(action="closed"))
|
await github_webhooks.process_github_pr_close(_pr_close_payload(action="closed"))
|
||||||
fake_set.assert_not_called()
|
fake_set.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.utils import slack as slack_utils
|
from agent.utils import slack as slack_utils
|
||||||
from agent.utils.slack import (
|
from agent.utils.slack import (
|
||||||
TRACE_REPLY_TIPS,
|
TRACE_REPLY_TIPS,
|
||||||
|
|
@ -15,7 +14,9 @@ from agent.utils.slack import (
|
||||||
select_slack_context_messages,
|
select_slack_context_messages,
|
||||||
strip_bot_mention,
|
strip_bot_mention,
|
||||||
)
|
)
|
||||||
from agent.webapp import generate_thread_id_from_slack_thread
|
from agent.utils.thread_ids import generate_thread_id_from_slack_thread
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import slack as slack_webhooks
|
||||||
|
|
||||||
_TEXT_ONLY_MODEL = "fireworks:accounts/fireworks/models/glm-5p2"
|
_TEXT_ONLY_MODEL = "fireworks:accounts/fireworks/models/glm-5p2"
|
||||||
_VISION_MODEL = "bedrock_converse:us.anthropic.claude-opus-4-8"
|
_VISION_MODEL = "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||||
|
|
@ -386,12 +387,12 @@ def test_get_slack_repo_config_uses_existing_thread_repo(
|
||||||
posted = True
|
posted = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "post_slack_thread_reply", fake_post_slack_thread_reply, raising=False
|
webhook_common, "post_slack_thread_reply", fake_post_slack_thread_reply, raising=False
|
||||||
)
|
)
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
assert repo == {"owner": "saved-owner", "name": "saved-repo"}
|
assert repo == {"owner": "saved-owner", "name": "saved-repo"}
|
||||||
assert threads_client.requested_thread_id == generate_thread_id_from_slack_thread(
|
assert threads_client.requested_thread_id == generate_thread_id_from_slack_thread(
|
||||||
|
|
@ -408,13 +409,13 @@ def test_get_slack_repo_config_new_thread_uses_default(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
threads_client = _FakeThreadsClient(raise_not_found=True)
|
threads_client = _FakeThreadsClient(raise_not_found=True)
|
||||||
monkeypatch.setattr(webapp, "SLACK_REPO_OWNER", "default-owner")
|
monkeypatch.setattr(webhook_common, "SLACK_REPO_OWNER", "default-owner")
|
||||||
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "default-repo")
|
monkeypatch.setattr(webhook_common, "SLACK_REPO_NAME", "default-repo")
|
||||||
monkeypatch.setattr(webapp, "get_team_default_repo", _no_team_default_repo)
|
monkeypatch.setattr(webhook_common, "get_team_default_repo", _no_team_default_repo)
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
assert repo == {"owner": "default-owner", "name": "default-repo"}
|
assert repo == {"owner": "default-owner", "name": "default-repo"}
|
||||||
|
|
||||||
|
|
@ -423,13 +424,13 @@ def test_get_slack_repo_config_existing_thread_without_repo_uses_default(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
threads_client = _FakeThreadsClient(thread={"metadata": {}})
|
threads_client = _FakeThreadsClient(thread={"metadata": {}})
|
||||||
monkeypatch.setattr(webapp, "SLACK_REPO_OWNER", "default-owner")
|
monkeypatch.setattr(webhook_common, "SLACK_REPO_OWNER", "default-owner")
|
||||||
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "default-repo")
|
monkeypatch.setattr(webhook_common, "SLACK_REPO_NAME", "default-repo")
|
||||||
monkeypatch.setattr(webapp, "get_team_default_repo", _no_team_default_repo)
|
monkeypatch.setattr(webhook_common, "get_team_default_repo", _no_team_default_repo)
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
assert repo == {"owner": "default-owner", "name": "default-repo"}
|
assert repo == {"owner": "default-owner", "name": "default-repo"}
|
||||||
assert threads_client.requested_thread_id == generate_thread_id_from_slack_thread(
|
assert threads_client.requested_thread_id == generate_thread_id_from_slack_thread(
|
||||||
|
|
@ -444,9 +445,9 @@ def test_get_slack_repo_config_ignores_repo_syntax_in_message(
|
||||||
thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}}
|
thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}}
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
assert repo == {"owner": "saved-owner", "name": "saved-repo"}
|
assert repo == {"owner": "saved-owner", "name": "saved-repo"}
|
||||||
|
|
||||||
|
|
@ -459,11 +460,13 @@ def test_get_slack_repo_config_uses_prefetched_channel_context(
|
||||||
async def fail_get_slack_channel_description(channel_id: str) -> str:
|
async def fail_get_slack_channel_description(channel_id: str) -> str:
|
||||||
raise AssertionError("prefetched channel context should avoid a duplicate Slack lookup")
|
raise AssertionError("prefetched channel context should avoid a duplicate Slack lookup")
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
monkeypatch.setattr(webapp, "get_slack_channel_description", fail_get_slack_channel_description)
|
monkeypatch.setattr(
|
||||||
|
webhook_common, "get_slack_channel_description", fail_get_slack_channel_description
|
||||||
|
)
|
||||||
|
|
||||||
repo = asyncio.run(
|
repo = asyncio.run(
|
||||||
webapp.get_slack_repo_config(
|
webhook_common.get_slack_repo_config(
|
||||||
"C123",
|
"C123",
|
||||||
"1.234",
|
"1.234",
|
||||||
channel_context={
|
channel_context={
|
||||||
|
|
@ -495,14 +498,14 @@ def test_get_slack_repo_config_applies_profile_default_repo(
|
||||||
assert login == "mason"
|
assert login == "mason"
|
||||||
return {"owner": "profile-owner", "name": "profile-repo"}
|
return {"owner": "profile-owner", "name": "profile-repo"}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info)
|
monkeypatch.setattr(webhook_common, "get_slack_user_info", fake_get_slack_user_info)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "resolve_login_from_email_async", fake_resolve_login_from_email_async
|
webhook_common, "resolve_login_from_email_async", fake_resolve_login_from_email_async
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(webapp, "get_profile_default_repo", fake_get_profile_default_repo)
|
monkeypatch.setattr(webhook_common, "get_profile_default_repo", fake_get_profile_default_repo)
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234", slack_user_id="U123"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234", slack_user_id="U123"))
|
||||||
|
|
||||||
assert repo == {"owner": "profile-owner", "name": "profile-repo"}
|
assert repo == {"owner": "profile-owner", "name": "profile-repo"}
|
||||||
|
|
||||||
|
|
@ -515,12 +518,12 @@ def test_get_slack_repo_config_applies_team_default_repo(
|
||||||
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
async def fake_get_team_default_repo() -> dict[str, str] | None:
|
||||||
return {"owner": "team-owner", "name": "team-repo"}
|
return {"owner": "team-owner", "name": "team-repo"}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
|
||||||
monkeypatch.setattr(webapp, "get_team_default_repo", fake_get_team_default_repo)
|
monkeypatch.setattr(webhook_common, "get_team_default_repo", fake_get_team_default_repo)
|
||||||
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "")
|
monkeypatch.setattr(webhook_common, "SLACK_REPO_NAME", "")
|
||||||
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
|
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "")
|
||||||
|
|
||||||
repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234"))
|
repo = asyncio.run(webhook_common.get_slack_repo_config("C123", "1.234"))
|
||||||
|
|
||||||
assert repo == {"owner": "team-owner", "name": "team-repo"}
|
assert repo == {"owner": "team-owner", "name": "team-repo"}
|
||||||
|
|
||||||
|
|
@ -583,12 +586,14 @@ def _setup_slack_mention_fakes(
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
threads = _FakeThreadsClientForProcess()
|
threads = _FakeThreadsClientForProcess()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
|
||||||
monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info)
|
monkeypatch.setattr(webhook_common, "get_slack_user_info", fake_get_slack_user_info)
|
||||||
monkeypatch.setattr(webapp, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages)
|
|
||||||
monkeypatch.setattr(webapp, "get_slack_user_names", fake_get_slack_user_names)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "resolve_slack_links_in_context", fake_resolve_slack_links_in_context
|
webhook_common, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_slack_user_names", fake_get_slack_user_names)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webhook_common, "resolve_slack_links_in_context", fake_resolve_slack_links_in_context
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_login_for_slack_id(slack_user_id):
|
async def fake_login_for_slack_id(slack_user_id):
|
||||||
|
|
@ -606,13 +611,13 @@ def _setup_slack_mention_fakes(
|
||||||
async def fake_post_prompt(*args, **kwargs) -> None:
|
async def fake_post_prompt(*args, **kwargs) -> None:
|
||||||
captured["prompt"] = {"args": args, "kwargs": kwargs}
|
captured["prompt"] = {"args": args, "kwargs": kwargs}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
monkeypatch.setattr(webhook_common, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
||||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
|
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClientForProcess())
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
|
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
|
||||||
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
|
monkeypatch.setattr(webhook_common, "refresh_user_mapping_cache", fake_refresh_cache)
|
||||||
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
|
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
|
||||||
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
|
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
|
||||||
|
|
||||||
|
|
||||||
def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
|
def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
|
||||||
|
|
@ -625,14 +630,14 @@ def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
|
||||||
captured["thread_exists_check"] = thread_id
|
captured["thread_exists_check"] = thread_id
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
|
|
||||||
thread_ts = "1700000000.000100"
|
thread_ts = "1700000000.000100"
|
||||||
event_ts = "1700000000.000200"
|
event_ts = "1700000000.000200"
|
||||||
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"channel_context": {
|
"channel_context": {
|
||||||
|
|
@ -724,13 +729,15 @@ def test_process_slack_mention_uses_vision_fallback_for_image_thread(
|
||||||
captured["image_url"] = image_url
|
captured["image_url"] = image_url
|
||||||
return {"type": "image", "source_type": "base64", "mime_type": "image/png", "data": "abc"}
|
return {"type": "image", "source_type": "base64", "mime_type": "image/png", "data": "abc"}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages)
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr(webapp, "resolve_agent_model_id", fake_resolve_agent_model_id)
|
webhook_common, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages
|
||||||
monkeypatch.setattr(webapp, "fetch_image_block", fake_fetch_image_block)
|
)
|
||||||
|
monkeypatch.setattr(webhook_common, "resolve_agent_model_id", fake_resolve_agent_model_id)
|
||||||
|
monkeypatch.setattr(webhook_common, "fetch_image_block", fake_fetch_image_block)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -764,13 +771,13 @@ def test_process_slack_mention_includes_channel_context_in_prompt(
|
||||||
async def fake_thread_exists(thread_id: str) -> bool:
|
async def fake_thread_exists(thread_id: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
|
|
||||||
thread_ts = "1700000000.000100"
|
thread_ts = "1700000000.000100"
|
||||||
event_ts = "1700000000.000200"
|
event_ts = "1700000000.000200"
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"channel_context": {
|
"channel_context": {
|
||||||
|
|
@ -817,12 +824,12 @@ def test_process_slack_mention_persists_trace_message_ts_on_first_mention(
|
||||||
async def fake_store_slack_run_mapping(*args, **kwargs) -> None:
|
async def fake_store_slack_run_mapping(*args, **kwargs) -> None:
|
||||||
captured["run_mapping"] = {"args": args, "kwargs": kwargs}
|
captured["run_mapping"] = {"args": args, "kwargs": kwargs}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
monkeypatch.setattr(webhook_common, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
||||||
monkeypatch.setattr(webapp, "store_slack_run_mapping", fake_store_slack_run_mapping)
|
monkeypatch.setattr(webhook_common, "store_slack_run_mapping", fake_store_slack_run_mapping)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -849,10 +856,10 @@ def test_process_slack_mention_prompt_omits_missing_channel_metadata(
|
||||||
async def fake_thread_exists(thread_id: str) -> bool:
|
async def fake_thread_exists(thread_id: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -883,14 +890,14 @@ def test_process_slack_mention_skips_trace_reply_on_followup_mention(
|
||||||
captured["thread_exists_check"] = thread_id
|
captured["thread_exists_check"] = thread_id
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
|
|
||||||
thread_ts = "1700000000.000100"
|
thread_ts = "1700000000.000100"
|
||||||
event_ts = "1700000000.000300"
|
event_ts = "1700000000.000300"
|
||||||
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": thread_ts,
|
"thread_ts": thread_ts,
|
||||||
|
|
@ -932,13 +939,13 @@ def test_process_slack_mention_unmapped_user_blocked_and_prompted(
|
||||||
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
||||||
captured["prompt"] = {"user_id": user_id, "user_email": user_email, "reason": reason}
|
captured["prompt"] = {"user_id": user_id, "user_email": user_email, "reason": reason}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
|
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
|
||||||
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
|
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -981,14 +988,14 @@ def test_process_slack_mention_mapped_user_no_token_record_prompts_setup(
|
||||||
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
||||||
captured["prompt"] = {"reason": reason}
|
captured["prompt"] = {"reason": reason}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
|
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
|
||||||
monkeypatch.setattr(webapp, "has_access_token_record", fake_has_token_record)
|
monkeypatch.setattr(webhook_common, "has_access_token_record", fake_has_token_record)
|
||||||
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
|
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -1027,14 +1034,14 @@ def test_process_slack_mention_mapped_user_unusable_token_prompts_revoked(
|
||||||
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
|
||||||
captured["prompt"] = {"reason": reason}
|
captured["prompt"] = {"reason": reason}
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
|
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
|
||||||
monkeypatch.setattr(webapp, "has_access_token_record", fake_has_token_record)
|
monkeypatch.setattr(webhook_common, "has_access_token_record", fake_has_token_record)
|
||||||
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
|
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
@ -1069,9 +1076,9 @@ def test_process_slack_mention_mapped_user_with_token_runs_as_user(
|
||||||
async def fake_upsert_owner(thread_id: str, **kwargs: object) -> None:
|
async def fake_upsert_owner(thread_id: str, **kwargs: object) -> None:
|
||||||
owner_meta.update(kwargs)
|
owner_meta.update(kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "upsert_agent_thread_owner_metadata", fake_upsert_owner)
|
monkeypatch.setattr(webhook_common, "upsert_agent_thread_owner_metadata", fake_upsert_owner)
|
||||||
|
|
||||||
channel_context = {
|
channel_context = {
|
||||||
"id": "C123",
|
"id": "C123",
|
||||||
|
|
@ -1083,7 +1090,7 @@ def test_process_slack_mention_mapped_user_with_token_runs_as_user(
|
||||||
}
|
}
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"channel_context": channel_context,
|
"channel_context": channel_context,
|
||||||
|
|
@ -1128,13 +1135,13 @@ def test_process_slack_mention_bot_only_mode_runs_without_user_token(
|
||||||
async def fake_login_for_email(email):
|
async def fake_login_for_email(email):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
|
||||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
|
||||||
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
|
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
|
||||||
monkeypatch.setattr(webapp, "is_bot_token_only_mode", lambda: True)
|
monkeypatch.setattr(webhook_common, "is_bot_token_only_mode", lambda: True)
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
webapp.process_slack_mention(
|
slack_webhooks.process_slack_mention(
|
||||||
{
|
{
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent import webapp
|
|
||||||
from agent.utils import slack_feedback
|
from agent.utils import slack_feedback
|
||||||
from agent.utils.slack_feedback import (
|
from agent.utils.slack_feedback import (
|
||||||
process_slack_reaction_added,
|
process_slack_reaction_added,
|
||||||
process_slack_reaction_removed,
|
process_slack_reaction_removed,
|
||||||
)
|
)
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
|
from agent.webhooks import slack_routes
|
||||||
|
|
||||||
|
|
||||||
class _FakeStore:
|
class _FakeStore:
|
||||||
|
|
@ -221,12 +222,12 @@ async def test_slack_webhook_queues_reaction_added(monkeypatch: pytest.MonkeyPat
|
||||||
payload = {"type": "event_callback", "event_id": "Ev1", "event": event}
|
payload = {"type": "event_callback", "event_id": "Ev1", "event": event}
|
||||||
background_tasks = _FakeBackgroundTasks()
|
background_tasks = _FakeBackgroundTasks()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "verify_slack_signature", lambda **kwargs: True)
|
monkeypatch.setattr(webhook_common, "verify_slack_signature", lambda **kwargs: True)
|
||||||
|
|
||||||
response = await webapp.slack_webhook(_FakeRequest(payload), background_tasks)
|
response = await slack_routes.slack_webhook(_FakeRequest(payload), background_tasks)
|
||||||
|
|
||||||
assert response == {"status": "accepted", "message": "Reaction feedback queued"}
|
assert response == {"status": "accepted", "message": "Reaction feedback queued"}
|
||||||
assert background_tasks.tasks == [(webapp.process_slack_reaction_added, (event, "Ev1"))]
|
assert background_tasks.tasks == [(webhook_common.process_slack_reaction_added, (event, "Ev1"))]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -235,12 +236,14 @@ async def test_slack_webhook_queues_reaction_removed(monkeypatch: pytest.MonkeyP
|
||||||
payload = {"type": "event_callback", "event_id": "Ev2", "event": event}
|
payload = {"type": "event_callback", "event_id": "Ev2", "event": event}
|
||||||
background_tasks = _FakeBackgroundTasks()
|
background_tasks = _FakeBackgroundTasks()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "verify_slack_signature", lambda **kwargs: True)
|
monkeypatch.setattr(webhook_common, "verify_slack_signature", lambda **kwargs: True)
|
||||||
|
|
||||||
response = await webapp.slack_webhook(_FakeRequest(payload), background_tasks)
|
response = await slack_routes.slack_webhook(_FakeRequest(payload), background_tasks)
|
||||||
|
|
||||||
assert response == {"status": "accepted", "message": "Reaction removal queued"}
|
assert response == {"status": "accepted", "message": "Reaction removal queued"}
|
||||||
assert background_tasks.tasks == [(webapp.process_slack_reaction_removed, (event, "Ev2"))]
|
assert background_tasks.tasks == [
|
||||||
|
(webhook_common.process_slack_reaction_removed, (event, "Ev2"))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -249,9 +252,9 @@ async def test_slack_webhook_ignores_untracked_reaction(monkeypatch: pytest.Monk
|
||||||
payload = {"type": "event_callback", "event_id": "Ev3", "event": event}
|
payload = {"type": "event_callback", "event_id": "Ev3", "event": event}
|
||||||
background_tasks = _FakeBackgroundTasks()
|
background_tasks = _FakeBackgroundTasks()
|
||||||
|
|
||||||
monkeypatch.setattr(webapp, "verify_slack_signature", lambda **kwargs: True)
|
monkeypatch.setattr(webhook_common, "verify_slack_signature", lambda **kwargs: True)
|
||||||
|
|
||||||
response = await webapp.slack_webhook(_FakeRequest(payload), background_tasks)
|
response = await slack_routes.slack_webhook(_FakeRequest(payload), background_tasks)
|
||||||
|
|
||||||
assert response == {"status": "ignored", "reason": "Reaction not tracked for feedback"}
|
assert response == {"status": "ignored", "reason": "Reaction not tracked for feedback"}
|
||||||
assert background_tasks.tasks == []
|
assert background_tasks.tasks == []
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from agent.webhooks import common as webhook_common
|
||||||
from agent.webhooks import slack as slack_webhook
|
from agent.webhooks import slack as slack_webhook
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,19 +33,17 @@ async def test_slack_processing_error_posts_dashboard_link(
|
||||||
post_reply = AsyncMock(return_value=True)
|
post_reply = AsyncMock(return_value=True)
|
||||||
|
|
||||||
monkeypatch.setattr(slack_webhook, "_process_slack_mention_impl", fail_processing)
|
monkeypatch.setattr(slack_webhook, "_process_slack_mention_impl", fail_processing)
|
||||||
|
monkeypatch.setattr(webhook_common, "generate_thread_id_from_slack_thread", lambda *_: "t1")
|
||||||
|
monkeypatch.setattr(webhook_common, "strip_bot_mention", lambda text, *_args, **_kwargs: text)
|
||||||
|
monkeypatch.setattr(webhook_common, "upsert_agent_thread_owner_metadata", upsert)
|
||||||
|
monkeypatch.setattr(webhook_common, "get_client", lambda *, url: client)
|
||||||
|
monkeypatch.setattr(webhook_common, "set_slack_assistant_status", set_status)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
slack_webhook.webapp, "generate_thread_id_from_slack_thread", lambda *_: "t1"
|
webhook_common,
|
||||||
|
"dashboard_thread_url",
|
||||||
|
lambda thread_id: f"https://ui/{thread_id}",
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", post_reply)
|
||||||
slack_webhook.webapp, "strip_bot_mention", lambda text, *_args, **_kwargs: text
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(slack_webhook.webapp, "upsert_agent_thread_owner_metadata", upsert)
|
|
||||||
monkeypatch.setattr(slack_webhook.webapp, "get_client", lambda *, url: client)
|
|
||||||
monkeypatch.setattr(slack_webhook.webapp, "set_slack_assistant_status", set_status)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
slack_webhook.webapp, "dashboard_thread_url", lambda thread_id: f"https://ui/{thread_id}"
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(slack_webhook.webapp, "post_slack_thread_reply", post_reply)
|
|
||||||
|
|
||||||
await slack_webhook.process_slack_mention(
|
await slack_webhook.process_slack_mention(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue