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:
Adam Moussa 2026-07-17 14:30:05 -04:00
parent 532788d409
commit b3fc62da80
No known key found for this signature in database
46 changed files with 3894 additions and 3534 deletions

1
agent/api/__init__.py Normal file
View file

@ -0,0 +1 @@
"""FastAPI application composition."""

57
agent/api/app.py Normal file
View 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
View 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)

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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,

File diff suppressed because it is too large Load diff

1732
agent/webhooks/common.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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,

View 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"}

View file

@ -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"],

View 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}"}

View file

@ -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)

View 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"}

View file

@ -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)

View 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"}

View file

@ -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,

View 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"}

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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"

View file

@ -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",

View file

@ -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 == []

View file

@ -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",

View file

@ -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",
) )

View file

@ -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") == {}

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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",

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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",

View file

@ -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 == []

View file

@ -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(
{ {