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

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]:
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(
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 ..utils.github_app import get_github_app_installation_token_with_expiry
from ..utils.slack import GitHubPrRef
from ..webapp import fetch_github_pr_metadata
from ..webhooks.common import fetch_github_pr_metadata
pr_ref = GitHubPrRef(
owner=owner,

View file

@ -29,7 +29,7 @@ _ASSISTANT_ID = "analyzer"
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")
if url:
return get_client(url=url)

View file

@ -14,7 +14,7 @@ async def trigger_pr_review_from_ref(
slack_channel_id: str = "",
slack_thread_ts: str = "",
) -> 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(
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 agent import webapp
from agent.utils import atlassian_connect as ac
from . import common
# 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
# / 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)
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"
# 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
# clientKey logged here to CONNECT_EXPECTED_CLIENT_KEYS and re-install.
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",
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
# host must be our Confluence site.
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"
existing = await ac.get_installation(client_key)
await ac.put_installation(
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",
"first-install" if existing is None else "re-install",
client_key,
@ -78,13 +79,13 @@ async def process_uninstall(request: Any, body: dict[str, Any]) -> tuple[int, st
return 400, "Missing clientKey"
claims = await ac.verify_asymmetric_install_jwt(request, expected_client_key=client_key)
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"
existing = await ac.get_installation(client_key)
if existing is None:
return 204, "" # idempotent
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, ""
@ -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."""
comment_id = _extract_comment_id(payload)
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
server_comment = await webapp.fetch_confluence_comment(comment_id)
server_comment = await common.fetch_confluence_comment(comment_id)
if not server_comment:
webapp.logger.warning(
common.logger.warning(
"Rejecting Confluence webhook: comment %s could not be corroborated", comment_id
)
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
# replies can echo "@openswe" and otherwise re-trigger).
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
for prefix in webapp._GITHUB_BOT_MESSAGE_PREFIXES:
for prefix in common._GITHUB_BOT_MESSAGE_PREFIXES:
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
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
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:
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:
repo_config = await webapp.get_team_default_repo()
repo_config = await common.get_team_default_repo()
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
if not webapp._is_repo_allowed(repo_config):
webapp.logger.warning(
if not common._is_repo_allowed(repo_config):
common.logger.warning(
"Rejecting Confluence webhook: repo '%s/%s' not in allowlist",
repo_config.get("owner"),
repo_config.get("name"),
)
return
mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None
if mapped_login and not webapp.is_login_mapped(mapped_login):
webapp.logger.info(
mapped_login = await common.resolve_login_from_email_async(actor_email) if actor_email else None
if mapped_login and not common.is_login_mapped(mapped_login):
common.logger.info(
"Confluence actor login %s is not an active mapping; running unattributed", mapped_login
)
mapped_login = None
thread_id = webapp.generate_thread_id_from_confluence_comment(client_key, comment_id)
page = await webapp.fetch_confluence_page(page_id) if page_id else None
thread_id = common.generate_thread_id_from_confluence_comment(client_key, comment_id)
page = await common.fetch_confluence_page(page_id) if page_id else None
page_title = (page or {}).get("title", "") or "Confluence page"
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:
configurable["github_login"] = mapped_login
await webapp.upsert_agent_thread_owner_metadata(
await common.upsert_agent_thread_owner_metadata(
thread_id,
source="confluence",
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"]},
)
run = await webapp.dispatch_agent_run(
run = await common.dispatch_agent_run(
thread_id,
content_blocks,
configurable,
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)",
thread_id,
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
object (``webapp.X``) so tests that monkeypatch them keep working.
Helpers and constants stay in common.py; they are accessed through the module
object (``common.X``) so tests that monkeypatch them keep working.
"""
import re
import uuid
from typing import Any
from agent import webapp
from ..ci_autofix import handle_ci_failure, handle_review_feedback
from ..dashboard.autofix_state import set_pr_autofix_disabled
from ..review.findings import FindingInteraction, ReviewerPRMeta, ReviewerSlackThread
@ -20,6 +18,7 @@ from ..utils.github_ci import (
)
from ..utils.github_comments import GitHubAuthError
from ..utils.slack import GitHubPrRef
from . import common
def build_github_issue_prompt(
@ -37,9 +36,9 @@ def build_github_issue_prompt(
"""Build the user prompt for a GitHub issue-triggered run."""
triggered_by_line = f"## Triggered by: {github_login}\n\n" if github_login else ""
issue_url_line = f"## Issue URL: {issue_url}\n\n" if issue_url else ""
comments_text = webapp._build_github_issue_comments_text(comments)
sanitized_title = webapp.sanitize_github_comment_body(title)
formatted_body = webapp.format_github_comment_body_for_prompt(
comments_text = common._build_github_issue_comments_text(comments)
sanitized_title = common.sanitize_github_comment_body(title)
formatted_body = common.format_github_comment_body_for_prompt(
issue_author or github_login, body
)
return (
@ -63,13 +62,13 @@ def build_github_issue_prompt(
def build_github_issue_followup_prompt(github_login: str, comment_body: str) -> str:
"""Build the prompt for a follow-up GitHub issue comment."""
return f"**{github_login}:**\n{webapp.format_github_comment_body_for_prompt(github_login, comment_body)}"
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:
"""Build the prompt for a follow-up GitHub issue title/body update."""
sanitized_title = webapp.sanitize_github_comment_body(title)
formatted_body = webapp.format_github_comment_body_for_prompt(github_login, body)
sanitized_title = common.sanitize_github_comment_body(title)
formatted_body = common.format_github_comment_body_for_prompt(github_login, body)
return (
f"**{github_login}:** updated the GitHub issue title/body.\n\n"
f"Title: {sanitized_title}\n\n"
@ -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);
# 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:
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"}
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:
return {"success": False, "error": "Could not fetch pull request metadata"}
repo_private = webapp._repo_private_from_pr_metadata(pr_metadata)
repo_id = webapp._repo_id_from_pr_metadata(pr_metadata)
app_token, app_token_expires_at = await webapp._reviewer_token_for_repo(
repo_private = common._repo_private_from_pr_metadata(pr_metadata)
repo_id = common._repo_id_from_pr_metadata(pr_metadata)
app_token, app_token_expires_at = await common._reviewer_token_for_repo(
repo_config,
repo_private=repo_private,
repo_id=repo_id,
)
if not app_token:
webapp.logger.warning("No GitHub App token available for PR reviewer request")
common.logger.warning("No GitHub App token available for PR reviewer request")
return {"success": False, "error": "No GitHub App token available"}
base_sha = pr_metadata.get("base", {}).get("sha", "")
@ -138,12 +137,12 @@ async def trigger_pr_review_from_ref(
pr_title = pr_metadata.get("title", "")
pr_url = pr_metadata.get("html_url", "") or pr_ref.url
if not base_sha or not head_sha:
webapp.logger.warning("Missing base/head SHA for Slack PR review request")
common.logger.warning("Missing base/head SHA for Slack PR review request")
return {"success": False, "error": "Pull request metadata is missing base/head SHA"}
thread_id = webapp.generate_reviewer_thread_id(pr_ref.owner, pr_ref.repo, pr_ref.number)
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
thread_id = common.generate_reviewer_thread_id(pr_ref.owner, pr_ref.repo, pr_ref.number)
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
return {"success": False, "error": "Could not create reviewer thread"}
pr_meta: ReviewerPRMeta = {
@ -162,10 +161,10 @@ async def trigger_pr_review_from_ref(
"channel_id": slack_channel_id,
"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
)
await webapp.post_review_started_comment(
await common.post_review_started_comment(
thread_id=thread_id,
owner=pr_ref.owner,
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)
configurable = webapp._build_reviewer_configurable(
configurable = common._build_reviewer_configurable(
source=source,
github_login=github_login,
github_user_id=github_user_id,
@ -189,19 +188,19 @@ async def trigger_pr_review_from_ref(
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
)
run = await webapp.dispatch_agent_run(
run = await common.dispatch_agent_run(
thread_id,
prompt,
configurable,
source=source,
assistant_id="reviewer",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
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}
@ -213,8 +212,8 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
"owner": repo.get("owner", {}).get("login", ""),
"name": repo.get("name", ""),
}
repo_private = webapp._repo_private_from_payload(payload)
repo_id = webapp._repo_id_from_payload(payload)
repo_private = common._repo_private_from_payload(payload)
repo_id = common._repo_id_from_payload(payload)
pr_number = pull_request.get("number")
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
branch_name = pull_request.get("head", {}).get("ref", "")
@ -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")
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
thread_id = webapp.generate_reviewer_thread_id(
thread_id = common.generate_reviewer_thread_id(
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 = ""
if payload.get("action") == "ready_for_review":
metadata = await webapp._get_thread_metadata_safe(thread_id)
if metadata is not None and metadata.get("kind") == webapp.REVIEWER_THREAD_KIND:
metadata = await common._get_thread_metadata_safe(thread_id)
if metadata is not None and metadata.get("kind") == common.REVIEWER_THREAD_KIND:
existing_last_reviewed_sha = metadata.get("last_reviewed_sha")
if isinstance(existing_last_reviewed_sha, str) and existing_last_reviewed_sha:
if existing_last_reviewed_sha == head_sha:
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
webapp.logger.info(
await common.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
common.logger.info(
"Skipping ready_for_review auto-review for %s/%s#%s: "
"head_sha unchanged from last_reviewed_sha",
repo_config.get("owner"),
@ -261,30 +260,30 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
return
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_private=repo_private,
repo_id=repo_id,
)
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
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
return
await webapp.set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
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", ""),
repo=repo_config.get("name", ""),
head_sha=head_sha,
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:
await webapp.set_reviewer_thread_metadata(
await common.set_reviewer_thread_metadata(
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:
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,
github_login=github_login,
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,
)
webapp.logger.info("Dispatching reviewer run for thread %s (source=%s)", thread_id, source)
run = await webapp.dispatch_agent_run(
common.logger.info("Dispatching reviewer run for thread %s (source=%s)", thread_id, source)
run = await common.dispatch_agent_run(
thread_id,
prompt,
configurable,
source=source,
assistant_id="reviewer",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
client=langgraph_client,
)
await webapp._store_current_reviewer_run_id(thread_id, run)
webapp.logger.info("Reviewer run dispatched for thread %s (source=%s)", thread_id, source)
await common._store_current_reviewer_run_id(thread_id, run)
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:
@ -337,8 +336,8 @@ async def process_github_pr_ready(payload: dict[str, Any]) -> None:
if is_draft:
author = pull_request.get("user") or {}
author_login = author.get("login", "") if isinstance(author, dict) else ""
if not await webapp._draft_review_enabled_for_author(author_login):
webapp.logger.info(
if not await common._draft_review_enabled_for_author(author_login):
common.logger.info(
"Skipping auto-review of draft PR by %s: review_draft_prs is disabled",
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):
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
)
metadata = await webapp._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
metadata = await common._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
# 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",
repo_config.get("owner"),
repo_config.get("name"),
@ -384,8 +383,8 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None:
if action == "converted_to_draft":
author = pull_request.get("user") or {}
author_login = author.get("login", "") if isinstance(author, dict) else ""
if await webapp._draft_review_enabled_for_author(author_login):
webapp.logger.info(
if await common._draft_review_enabled_for_author(author_login):
common.logger.info(
"PR %s/%s#%s converted to draft but author %s has draft reviews enabled; keeping watch",
repo_config.get("owner"),
repo_config.get("name"),
@ -398,8 +397,8 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None:
desired_watch = action == "reopened"
if metadata.get("watch") == desired_watch:
return
await webapp.set_reviewer_thread_metadata(thread_id, watch=desired_watch)
webapp.logger.info(
await common.set_reviewer_thread_metadata(thread_id, watch=desired_watch)
common.logger.info(
"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", "")
after_sha = payload.get("after", "")
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
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
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", ""),
"name": repo.get("name", ""),
}
repo_private = webapp._repo_private_from_payload(payload)
repo_id = webapp._repo_id_from_payload(payload)
repo_private = common._repo_private_from_payload(payload)
repo_id = common._repo_id_from_payload(payload)
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
)
return
if not await webapp._is_repo_auto_review_enabled(repo_config):
webapp.logger.info(
if not await common._is_repo_auto_review_enabled(repo_config):
common.logger.info(
"Push to %s/%s head=%s ignored: automatic review disabled",
repo_config["owner"],
repo_config["name"],
@ -437,18 +436,18 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
)
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_private=repo_private,
repo_id=repo_id,
)
if not app_token:
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
common.logger.warning("No GitHub App token for push re-review on %s", head_ref)
return
pr = await webapp._fetch_open_pr_for_branch(repo_config, head_ref, token=app_token)
pr = await common._fetch_open_pr_for_branch(repo_config, head_ref, token=app_token)
if not pr:
webapp.logger.debug(
common.logger.debug(
"No open PR found for push to %s/%s head=%s",
repo_config["owner"],
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
# proxy a full-installation token for a public PR.
if repo_private is None:
repo_private = webapp._repo_private_from_pr_metadata(pr)
repo_id = repo_id or webapp._repo_id_from_pr_metadata(pr)
repo_private = common._repo_private_from_pr_metadata(pr)
repo_id = repo_id or common._repo_id_from_pr_metadata(pr)
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_private=repo_private,
repo_id=repo_id,
)
if not app_token:
webapp.logger.warning("No GitHub App token for push re-review on %s", head_ref)
common.logger.warning("No GitHub App token for push re-review on %s", head_ref)
return
pr_number = pr.get("number")
pr_url = pr.get("html_url") or pr.get("url") or ""
@ -478,7 +477,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
head_sha = pr.get("head", {}).get("sha", after_sha)
pr_title = pr.get("title", "")
if not isinstance(pr_number, int) or not base_sha or not head_sha:
webapp.logger.warning(
common.logger.warning(
"Push to %s/%s head=%s ignored: PR metadata missing number/base/head SHA",
repo_config["owner"],
repo_config["name"],
@ -486,12 +485,12 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
)
return
thread_id = webapp.generate_reviewer_thread_id(
thread_id = common.generate_reviewer_thread_id(
repo_config["owner"], repo_config["name"], pr_number
)
metadata = await webapp._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
webapp.logger.info(
metadata = await common._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
common.logger.info(
"Push to %s/%s#%s ignored: no reviewer thread for this PR. "
"Trigger a first review (Slack `@open-swe review <url>` or request "
"open-swe[bot] as a GitHub reviewer) to start watching.",
@ -501,21 +500,21 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
)
return
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
)
return
last_reviewed_sha = metadata.get("last_reviewed_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
)
return
if (
isinstance(last_reviewed_sha, str)
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,
base_ref=base_ref,
last_reviewed_sha=last_reviewed_sha,
@ -523,19 +522,19 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
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
# shows checks on the current head), so even though no re-review runs,
# surface a settled check on the new head.
unchanged_check_id = await webapp.create_review_check_run(
unchanged_check_id = await common.create_review_check_run(
owner=repo_config["owner"],
repo=repo_config["name"],
head_sha=head_sha,
token=app_token,
details_url=webapp.dashboard_thread_url(thread_id),
details_url=common.dashboard_thread_url(thread_id),
)
if unchanged_check_id is not None:
await webapp.complete_review_check_run(
await common.complete_review_check_run(
owner=repo_config["owner"],
repo=repo_config["name"],
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}."
),
)
webapp.logger.info(
common.logger.info(
"Push to %s ignored: PR diff unchanged since last reviewed SHA %s",
head_ref,
last_reviewed_sha,
)
return
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
if not await webapp._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
if not await common._ensure_thread_exists_for_metadata(thread_id, langgraph_client):
return
try:
threads = await webapp.fetch_pr_review_threads(
threads = await common.fetch_pr_review_threads(
owner=repo_config["owner"],
repo=repo_config["name"],
pr_number=pr_number,
token=app_token,
)
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
await common.reconcile_findings_with_review_threads(thread_id, threads)
except Exception:
webapp.logger.warning(
common.logger.warning(
"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,
"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
# created on the previous head disappears after a follow-up push. Create a
# fresh in-progress check on the new head SHA so the review stays visible;
# publish (or the after-agent hook) settles this id.
check_run_id = await webapp.create_review_check_run(
check_run_id = await common.create_review_check_run(
owner=repo_config["owner"],
repo=repo_config["name"],
head_sha=head_sha,
token=app_token,
details_url=webapp.dashboard_thread_url(thread_id),
details_url=common.dashboard_thread_url(thread_id),
)
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}
)
@ -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"net-new findings, and call `publish_review` once you're done."
)
configurable = webapp._build_reviewer_configurable(
configurable = common._build_reviewer_configurable(
source="github_push",
github_login=payload.get("sender", {}).get("login", "") or "",
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 "",
)
webapp.logger.info("Dispatching push re-review run for thread %s", thread_id)
run = await webapp.dispatch_agent_run(
common.logger.info("Dispatching push re-review run for thread %s", thread_id)
run = await common.dispatch_agent_run(
thread_id,
re_review_prompt,
configurable,
source="github_push",
assistant_id="reviewer",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
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:
@ -650,20 +649,20 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
pr_url,
comment_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")
webapp.logger.info(
common.logger.info(
"Processing GitHub PR comment: event=%s, pr=%s, branch=%s",
event_type,
pr_number,
branch_name,
)
thread_id = webapp.get_thread_id_from_branch(branch_name) if branch_name else None
thread_id = common.get_thread_id_from_branch(branch_name) if branch_name else None
if not thread_id:
if not pr_number:
webapp.logger.warning(
common.logger.warning(
"Could not determine thread_id for branch '%s' (no pr_number), skipping",
branch_name,
)
@ -672,21 +671,21 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
name = repo_config.get("name", "")
stable_key = f"{owner}/{name}/pr/{pr_number}"
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
webapp.logger.info(
common.logger.info(
"Generated thread_id %s for non-open-swe branch '%s'", thread_id, branch_name
)
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
try:
await langgraph_client.threads.update(thread_id, metadata={"branch_name": branch_name})
except Exception as exc: # noqa: BLE001
if webapp._is_not_found_error(exc):
if common._is_not_found_error(exc):
await langgraph_client.threads.create(
thread_id=thread_id,
if_exists="do_nothing",
metadata={"branch_name": branch_name},
)
else:
webapp.logger.warning(
common.logger.warning(
"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
# the Slack mention path (process_slack_mention).
try:
await webapp.refresh_user_mapping_cache()
await common.refresh_user_mapping_cache()
except Exception: # noqa: BLE001
webapp.logger.debug(
common.logger.debug(
"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:
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
)
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
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
if comment_id:
try:
await webapp.react_to_github_comment(
await common.react_to_github_comment(
repo_config,
comment_id,
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,
)
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
)
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
await webapp.react_to_github_comment(
await common.react_to_github_comment(
repo_config,
comment_id,
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:
webapp.logger.warning("No PR number found in payload, skipping")
common.logger.warning("No PR number found in payload, skipping")
return
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
)
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
)
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
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
)
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
prompt = webapp.build_pr_prompt(comments, pr_url, repo_config=repo_config)
await webapp._trigger_or_queue_run(
prompt = common.build_pr_prompt(comments, pr_url, repo_config=repo_config)
await common._trigger_or_queue_run(
thread_id,
prompt,
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:
"""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:
return
sender = payload.get("sender", {})
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
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", ""),
"name": repo.get("name", ""),
}
repo_private = webapp._repo_private_from_payload(payload)
repo_id = webapp._repo_id_from_payload(payload)
repo_private = common._repo_private_from_payload(payload)
repo_id = common._repo_id_from_payload(payload)
pr_number = pull_request.get("number")
if not isinstance(pr_number, int):
return
thread_id = webapp.generate_reviewer_thread_id(
thread_id = common.generate_reviewer_thread_id(
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
)
metadata = await webapp._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != webapp.REVIEWER_THREAD_KIND:
metadata = await common._get_thread_metadata_safe(thread_id)
if metadata is None or metadata.get("kind") != common.REVIEWER_THREAD_KIND:
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_private=repo_private,
repo_id=repo_id,
@ -812,16 +811,16 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
if not app_token:
return
threads = await webapp.fetch_pr_review_threads(
threads = await common.fetch_pr_review_threads(
owner=repo_config["owner"],
repo=repo_config["name"],
pr_number=pr_number,
token=app_token,
)
await webapp.reconcile_findings_with_review_threads(thread_id, threads)
findings = await webapp.list_reviewer_findings(thread_id)
await common.reconcile_findings_with_review_threads(thread_id, threads)
findings = await common.list_reviewer_findings(thread_id)
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:
return
@ -846,13 +845,13 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
else "",
"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", "")
head_sha = pull_request.get("head", {}).get("sha", "")
pr_url = pull_request.get("html_url", "") or pull_request.get("url", "")
branch_name = pull_request.get("head", {}).get("ref", "")
configurable = webapp._build_reviewer_configurable(
configurable = common._build_reviewer_configurable(
source="github_review_comment",
github_login=reply_author,
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_prompt = webapp._build_queued_finding_reply_prompt(
finding_reply_prompt = common._build_queued_finding_reply_prompt(
finding_id=finding_id,
reply_author=reply_author,
reply_body=reply_body,
pr_number=pr_number,
)
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
run = await webapp.dispatch_agent_run(
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
run = await common.dispatch_agent_run(
thread_id,
finding_reply_prompt,
configurable,
source="github_review_reply",
assistant_id="reviewer",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
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:
@ -910,7 +909,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
description = issue.get("body") or "No description"
issue_author = issue.get("user", {}).get("login", "")
webapp.logger.info(
common.logger.info(
"Processing GitHub issue: event=%s, issue=%s, repo=%s/%s",
event_type,
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:
webapp.logger.warning("Missing GitHub issue id/number, skipping")
common.logger.warning("Missing GitHub issue id/number, skipping")
return
# Refresh the per-process user-mapping cache from the Store before
# resolving the author's email (multi-replica staleness; mirrors the Slack
# mention path in process_slack_mention).
try:
await webapp.refresh_user_mapping_cache()
await common.refresh_user_mapping_cache()
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:
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
thread_id = webapp.generate_thread_id_from_github_issue(issue_id)
existing_thread = await webapp._thread_exists(thread_id)
github_token = await webapp._get_or_resolve_thread_github_token(
thread_id = common.generate_thread_id_from_github_issue(issue_id)
existing_thread = await common._thread_exists(thread_id)
github_token = await common._get_or_resolve_thread_github_token(
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
comment = payload.get("comment", {})
comment_id = comment.get("id")
if event_type == "issue_comment" and comment_id:
if not reaction_token:
webapp.logger.warning(
common.logger.warning(
"No GitHub token available to react to issue comment %s", comment_id
)
else:
try:
reacted = await webapp.react_to_github_comment(
reacted = await common.react_to_github_comment(
repo_config,
comment_id,
event_type="issue_comment",
token=reaction_token,
)
except GitHubAuthError:
github_token = await webapp._refresh_thread_github_token_after_401(
github_token = await common._refresh_thread_github_token_after_401(
thread_id, email, repo=repo_config
)
reaction_token = github_token or app_token
reacted = False
if reaction_token:
try:
reacted = await webapp.react_to_github_comment(
reacted = await common.react_to_github_comment(
repo_config,
comment_id,
event_type="issue_comment",
token=reaction_token,
)
except GitHubAuthError:
webapp.logger.warning(
common.logger.warning(
"Re-auth still produced 401 reacting to issue comment %s",
comment_id,
)
reacted = False
if not reacted:
webapp.logger.warning("Failed to react to GitHub issue comment %s", comment_id)
common.logger.warning("Failed to react to GitHub issue comment %s", comment_id)
if existing_thread:
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)
else:
try:
comments = await webapp.fetch_issue_comments(
comments = await common.fetch_issue_comments(
repo_config, issue_number, token=github_token or app_token
)
except GitHubAuthError:
github_token = await webapp._refresh_thread_github_token_after_401(
github_token = await common._refresh_thread_github_token_after_401(
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
)
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,
source="github",
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"]},
)
webapp.logger.info("Dispatching LangGraph run for thread %s from GitHub issue", thread_id)
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
await webapp.dispatch_agent_run(
common.logger.info("Dispatching LangGraph run for thread %s from GitHub issue", thread_id)
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
await common.dispatch_agent_run(
thread_id,
prompt,
configurable,
source="github_issue",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
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) ----
@ -1081,7 +1080,7 @@ async def process_github_ci_event(payload: dict[str, Any], event_type: str) -> N
head_sha=head_sha,
source="github_ci",
)
webapp.logger.info(
common.logger.info(
"CI auto-fix for %s/%s@%s (%s): %s",
repo_config["owner"],
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
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
match = _AUTOFIX_COMMAND_RE.search(comment_body)
if not match:
@ -1135,7 +1134,7 @@ async def process_github_autofix_command(
if ref is None:
return
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",
"disabled" if disabled else "enabled",
ref["owner"],
@ -1146,11 +1145,11 @@ async def process_github_autofix_command(
comment_id = comment.get("id")
if not isinstance(comment_id, int):
return
token = await webapp.get_github_app_installation_token()
token = await common.get_github_app_installation_token()
if not token:
return
try:
await webapp.react_to_github_comment(
await common.react_to_github_comment(
{"owner": ref["owner"], "name": ref["name"]},
comment_id,
event_type=event_type,
@ -1159,7 +1158,7 @@ async def process_github_autofix_command(
node_id=comment.get("node_id"),
)
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
@ -1192,7 +1191,7 @@ def _is_actionable_review_payload(payload: dict[str, Any], event_type: str) -> b
if not isinstance(node, dict):
return False
reviewer = (node.get("user") or {}).get("login", "")
if reviewer in webapp.INTERNAL_BOT_LOGINS:
if reviewer in common.INTERNAL_BOT_LOGINS:
return False
if node.get("author_association") not in _TRUSTED_REVIEW_ASSOCIATIONS:
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", {})
reviewer = (comment.get("user") or {}).get("login", "") if isinstance(comment, dict) else ""
body = (comment.get("body") or "") if isinstance(comment, dict) else ""
if not body.strip() or reviewer in webapp.INTERNAL_BOT_LOGINS:
if not body.strip() or reviewer in common.INTERNAL_BOT_LOGINS:
return
result = await handle_review_feedback(
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,
source="github_review",
)
webapp.logger.info(
common.logger.info(
"Auto-fix review feedback for %s/%s#%s: %s",
ref["owner"],
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.
Helpers and constants stay in webapp.py; they are accessed through the module
object (``webapp.X``) so tests that monkeypatch them keep working.
Helpers and constants stay in common.py; they are accessed through the module
object (``common.X``) so tests that monkeypatch them keep working.
"""
from typing import Any
@ -10,7 +10,7 @@ from urllib.parse import urlparse
import httpx
from langchain_core.messages.content import create_text_block
from agent import webapp
from . import common
async def process_jira_issue( # noqa: PLR0912, PLR0915
@ -20,20 +20,20 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
Args:
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.
"""
issue_key = issue_data.get("key", "")
webapp.logger.info(
common.logger.info(
"Processing Jira issue %s for repo %s/%s",
issue_key,
repo_config.get("owner"),
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:
full_issue = {}
@ -46,26 +46,26 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
user_name = comment_author.get("name") or None
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"
description = full_issue.get("description") or "No description"
image_urls: list[str] = []
description_image_urls = webapp.extract_image_urls(description)
description_image_urls = common.extract_image_urls(description)
if description_image_urls:
image_urls.extend(description_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in issue description",
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_text = ""
triggering_comment = issue_data.get("triggering_comment", "")
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_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)
if trigger_index is not None:
relevant_comments = comments[trigger_index:]
webapp.logger.debug(
common.logger.debug(
"Using triggering comment index %d to build relevant comments",
trigger_index,
)
else:
relevant_comments = webapp.get_recent_comments(comments, bot_message_prefixes)
relevant_comments = common.get_recent_comments(comments, bot_message_prefixes)
if relevant_comments:
comments_text = "\n\n## Comments:\n"
for comment in relevant_comments:
author = (comment.get("author") or {}).get("name") or "User"
body = comment.get("body", "")
body_image_urls = webapp.extract_image_urls(body)
body_image_urls = common.extract_image_urls(body)
if body_image_urls:
image_urls.extend(body_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in comment by %s",
len(body_image_urls),
author,
@ -111,16 +111,16 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
comments_text = "\n\n## Comments:\n"
trigger_author = comment_author.get("name") or "Unknown"
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:
image_urls.extend(trigger_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in triggering comment by %s",
len(trigger_image_urls),
trigger_author,
)
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",
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*
# and the thread is tagged for the dashboard. Restricted to the comment
# 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
# must never drive PR authorship or token resolution.
if mapped_login and not webapp.is_login_mapped(mapped_login):
webapp.logger.info(
if mapped_login and not common.is_login_mapped(mapped_login):
common.logger.info(
"Jira actor login %s is not an active mapping; running unattributed", mapped_login
)
mapped_login = None
image_model_override: tuple[str, str] | None = None
if image_urls:
image_urls = webapp.dedupe_urls(image_urls)
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
if not webapp.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
webapp.logger.info(
image_urls = common.dedupe_urls(image_urls)
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
if not common.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = common.default_vision_model_pair()
common.logger.info(
"Using vision fallback model %s for %d Jira image(s); configured model %s "
"does not support images",
fallback_model_id,
@ -176,15 +176,15 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
)
resolved_model_id = fallback_model_id
image_model_override = (fallback_model_id, fallback_effort)
webapp.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.info("Preparing %d image(s) for multimodal content", len(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:
image_block = await webapp.fetch_image_block(image_url, client)
image_block = await common.fetch_image_block(image_url, client)
if 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] = {
"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_effort"] = image_model_override[1]
await webapp.upsert_agent_thread_owner_metadata(
await common.upsert_agent_thread_owner_metadata(
thread_id,
source="jira",
repo_config=repo_config,
@ -215,16 +215,16 @@ async def process_jira_issue( # noqa: PLR0912, PLR0915
source_context={"jira_issue": configurable["jira_issue"]},
)
run = await webapp.dispatch_agent_run(
run = await common.dispatch_agent_run(
thread_id,
content_blocks,
configurable,
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)",
thread_id,
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
object (``webapp.X``) so tests that monkeypatch them keep working.
Helpers and constants stay in common.py; they are accessed through the module
object (``common.X``) so tests that monkeypatch them keep working.
"""
from typing import Any
@ -10,7 +10,7 @@ from urllib.parse import urlparse
import httpx
from langchain_core.messages.content import create_text_block
from agent import webapp
from . import common
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.
"""
issue_id = issue_data.get("id", "")
webapp.logger.info(
common.logger.info(
"Processing Linear issue %s for repo %s/%s",
issue_id,
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", "")
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:
full_issue = issue_data
@ -64,15 +64,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
user_email = assignee.get("email")
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")
description = full_issue.get("description") or "No description"
image_urls: list[str] = []
description_image_urls = webapp.extract_image_urls(description)
description_image_urls = common.extract_image_urls(description)
if description_image_urls:
image_urls.extend(description_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in issue description",
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)
if trigger_index is not None:
relevant_comments = comments[trigger_index:]
webapp.logger.debug(
common.logger.debug(
"Using triggering comment index %d to build relevant comments",
trigger_index,
)
else:
relevant_comments = webapp.get_recent_comments(comments, bot_message_prefixes)
relevant_comments = common.get_recent_comments(comments, bot_message_prefixes)
if relevant_comments:
comments_text = "\n\n## Comments:\n"
@ -120,10 +120,10 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
user = comment.get("user") or {}
author = user.get("name", "User")
body = comment.get("body", "")
body_image_urls = webapp.extract_image_urls(body)
body_image_urls = common.extract_image_urls(body)
if body_image_urls:
image_urls.extend(body_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in comment by %s",
len(body_image_urls),
author,
@ -137,16 +137,16 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
comments_text = "\n\n## Comments:\n"
trigger_author = comment_author.get("name", "Unknown")
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:
image_urls.extend(trigger_image_urls)
webapp.logger.debug(
common.logger.debug(
"Found %d image URL(s) in triggering comment by %s",
len(trigger_image_urls),
trigger_author,
)
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",
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
# thread is tagged for the dashboard. Restricted to the comment author so
# 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
if image_urls:
image_urls = webapp.dedupe_urls(image_urls)
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
if not webapp.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
webapp.logger.info(
image_urls = common.dedupe_urls(image_urls)
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
if not common.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = common.default_vision_model_pair()
common.logger.info(
"Using vision fallback model %s for %d Linear image(s); configured model %s "
"does not support images",
fallback_model_id,
@ -200,15 +200,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
)
resolved_model_id = fallback_model_id
image_model_override = (fallback_model_id, fallback_effort)
webapp.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.info("Preparing %d image(s) for multimodal content", len(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:
image_block = await webapp.fetch_image_block(image_url, client)
image_block = await common.fetch_image_block(image_url, client)
if 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_issue_number = ""
@ -237,7 +237,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
configurable["agent_model_id"] = image_model_override[0]
configurable["agent_effort"] = image_model_override[1]
await webapp.upsert_agent_thread_owner_metadata(
await common.upsert_agent_thread_owner_metadata(
thread_id,
source="linear",
repo_config=repo_config,
@ -247,16 +247,16 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
source_context={"linear_issue": configurable["linear_issue"]},
)
run = await webapp.dispatch_agent_run(
run = await common.dispatch_agent_run(
thread_id,
content_blocks,
configurable,
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)",
thread_id,
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
object (``webapp.X``) so tests that monkeypatch them keep working.
Helpers and constants stay in common.py; they are accessed through the module
object (``common.X``) so tests that monkeypatch them keep working.
"""
from datetime import UTC, datetime
@ -10,7 +10,7 @@ from typing import Any
import httpx
from langchain_core.messages.content import create_text_block
from agent import webapp
from . import common
def _format_slack_thread_section(
@ -31,9 +31,9 @@ def _format_slack_thread_section(
lines.append(f"- Channel name: #{channel_name}")
lines.append(f"- Thread TS: {thread_ts}")
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:
lines.extend(webapp.format_untrusted_channel_description(channel_description))
lines.extend(common.format_untrusted_channel_description(channel_description))
return "\n".join(lines)
@ -42,7 +42,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
try:
await _process_slack_mention_impl(event_data, repo_config)
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)
@ -58,13 +58,13 @@ async def _notify_slack_processing_error(
if not channel_id or not thread_ts:
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:
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"
)
await webapp.upsert_agent_thread_owner_metadata(
await common.upsert_agent_thread_owner_metadata(
thread_id,
source="slack",
repo_config=repo_config,
@ -79,12 +79,12 @@ async def _notify_slack_processing_error(
},
)
except Exception: # noqa: BLE001
webapp.logger.warning(
common.logger.warning(
"Could not persist Slack error metadata for thread %s", thread_id, exc_info=True
)
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,
metadata={
"latest_run_status": "error",
@ -92,14 +92,14 @@ async def _notify_slack_processing_error(
},
)
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:
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
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 = (
"⚠️ I hit an unexpected error while handling this Slack thread. "
"Send another message and I'll try again."
@ -107,9 +107,9 @@ async def _notify_slack_processing_error(
if dashboard_url:
message += f" You can view the error in <{dashboard_url}|Open SWE Web>."
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
webapp.logger.warning(
common.logger.warning(
"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_raw
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:
webapp.logger.warning(
common.logger.warning(
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
channel_id,
thread_ts,
@ -139,20 +139,20 @@ async def _process_slack_mention_impl(
)
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.
try:
await webapp.refresh_user_mapping_cache()
await common.refresh_user_mapping_cache()
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_name = ""
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:
profile = slack_user.get("profile", {})
if isinstance(profile, dict):
@ -165,26 +165,26 @@ async def _process_slack_mention_impl(
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):
thread_messages.append({"ts": event_ts, "text": text, "user": user_id})
context_messages, context_mode = webapp.select_slack_context_messages(
thread_messages, event_ts, bot_user_id, webapp.SLACK_BOT_USERNAME
context_messages, context_mode = common.select_slack_context_messages(
thread_messages, event_ts, bot_user_id, common.SLACK_BOT_USERNAME
)
context_user_ids = [
value
for value in (message.get("user") for message in context_messages)
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:
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,
user_names_by_id,
bot_user_id=bot_user_id,
bot_username=webapp.SLACK_BOT_USERNAME,
bot_username=common.SLACK_BOT_USERNAME,
)
context_source = (
"the previous message where I was tagged"
@ -192,13 +192,13 @@ async def _process_slack_mention_impl(
else "the beginning of the thread"
)
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)"
)
trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user")
# 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
)
@ -223,8 +223,8 @@ async def _process_slack_mention_impl(
)
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
image_urls = webapp.dedupe_urls(
[url for msg in context_messages for url in webapp.extract_image_urls(msg.get("text", ""))]
image_urls = common.dedupe_urls(
[url for msg in context_messages for url in common.extract_image_urls(msg.get("text", ""))]
+ [
f["url_private"]
for msg in context_messages
@ -236,16 +236,16 @@ async def _process_slack_mention_impl(
+ 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:
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
if image_urls:
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
if not webapp.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
webapp.logger.info(
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
if not common.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = common.default_vision_model_pair()
common.logger.info(
"Using vision fallback model %s for %d Slack image(s); configured model %s "
"does not support images",
fallback_model_id,
@ -254,10 +254,10 @@ async def _process_slack_mention_impl(
)
resolved_model_id = fallback_model_id
image_model_override = (fallback_model_id, fallback_effort)
webapp.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as http_client:
common.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
async with httpx.AsyncClient(timeout=common.DEFAULT_HTTP_TIMEOUT) as http_client:
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:
content_blocks.append(image_block)
@ -269,9 +269,9 @@ async def _process_slack_mention_impl(
user_token: str | None = None
if mapped_login:
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
webapp.logger.debug(
common.logger.debug(
"Failed to resolve GitHub token for %s; treating as unauthenticated",
mapped_login,
exc_info=True,
@ -279,7 +279,7 @@ async def _process_slack_mention_impl(
user_token = None
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
# means the user has never connected GitHub + Slack via the dashboard.
# 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
if mapped_login:
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
webapp.logger.debug(
common.logger.debug(
"Failed to check GitHub token record for %s; prompting sign-in",
mapped_login,
exc_info=True,
)
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)",
thread_id,
reason,
)
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
)
await webapp.set_slack_assistant_status(channel_id, thread_ts, status="")
await common.set_slack_assistant_status(channel_id, thread_ts, status="")
return
configurable: dict[str, Any] = {
@ -327,17 +327,17 @@ async def _process_slack_mention_impl(
configurable["agent_model_id"] = image_model_override[0]
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:
configurable["plan_mode"] = thread_plan_mode
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
is_first_mention = not await webapp._thread_exists(thread_id)
await webapp._upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
langgraph_client = common.get_client(url=common.LANGGRAPH_URL)
is_first_mention = not await common._thread_exists(thread_id)
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
# always tagged with github_login — the key the dashboard searches by. Without
# 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,
source="slack",
repo_config=repo_config,
@ -347,25 +347,25 @@ async def _process_slack_mention_impl(
source_context={"slack_thread": configurable["slack_thread"]},
)
run = await webapp.dispatch_agent_run(
run = await common.dispatch_agent_run(
thread_id,
content_blocks,
configurable,
source="slack",
metadata=webapp._AGENT_VERSION_METADATA,
metadata=common._AGENT_VERSION_METADATA,
client=langgraph_client,
)
webapp.logger.info(
common.logger.info(
"Slack LangGraph run %s dispatched for thread %s",
webapp._run_id_for_logging(run),
common._run_id_for_logging(run),
thread_id,
)
run_id = run.get("run_id")
if is_first_mention:
trace_message_ts = await webapp.post_slack_trace_reply(channel_id, thread_ts, thread_id)
await webapp.set_slack_assistant_status(channel_id, thread_ts)
trace_message_ts = await common.post_slack_trace_reply(channel_id, thread_ts, thread_id)
await common.set_slack_assistant_status(channel_id, thread_ts)
if isinstance(run_id, str) and run_id:
await webapp.store_slack_run_mapping(
await common.store_slack_run_mapping(
langgraph_client,
channel_id,
thread_ts,
@ -375,12 +375,12 @@ async def _process_slack_mention_impl(
triggering_user_id=user_id,
)
else:
webapp.logger.info(
common.logger.info(
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
thread_id,
)
if isinstance(run_id, str) and run_id:
await webapp.store_slack_run_mapping(
await common.store_slack_run_mapping(
langgraph_client,
channel_id,
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
from agent import webapp
from agent.webhooks import common as webhook_common
@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.
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:
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 |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
| 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** |
| `open_pull_request`, `slack_thread_reply` tools | **real** |
| 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).
- `agent_entrypoint.py` — langgraph `agent` graph: applies patches, re-exports the
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.
- `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.

View file

@ -1,7 +1,7 @@
"""Shared environment + constants for the full-flow E2E.
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.
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).
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 Slack API (/fake-slack/...) the real slack code hits this
- 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.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"]
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."""
import asyncio
from agent import webapp
from agent.webhooks import common as webhook_common
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
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}
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"]["thread_ts"] == "1.1"
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:
import asyncio
from agent import webapp
from agent.webhooks import common as webhook_common
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
calls: dict[str, object] = {}
@ -47,9 +49,11 @@ def test_account_link_prompt_revoked_wording(monkeypatch: pytest.MonkeyPatch) ->
calls["text"] = text
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 "link=" not in calls["text"]
@ -59,7 +63,7 @@ def test_account_link_prompt_skips_when_dashboard_url_unset(
) -> None:
import asyncio
from agent import webapp
from agent.webhooks import common as webhook_common
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
posted = False
@ -69,7 +73,9 @@ def test_account_link_prompt_skips_when_dashboard_url_unset(
posted = 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

View file

@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
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]:
@ -22,23 +22,25 @@ def _pr_payload(*, state: str, merged: bool = False, draft: bool = False) -> dic
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:
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:
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:
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:
assert webapp._pr_state_from_payload({}) is None
assert webhook_common._pr_state_from_payload({}) is None
@pytest.mark.asyncio
@ -54,8 +56,8 @@ async def test_update_agent_thread_pr_state_updates_matching_thread() -> None:
)
fake_client.threads.update = AsyncMock()
with patch("agent.webapp.get_client", return_value=fake_client):
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed"))
with patch("agent.webhooks.common.get_client", return_value=fake_client):
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed"))
fake_client.threads.search.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()
with patch("agent.webapp.get_client", return_value=fake_client):
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed"))
with patch("agent.webhooks.common.get_client", return_value=fake_client):
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed"))
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()
with patch("agent.webapp.get_client", return_value=fake_client):
await webapp.update_agent_thread_pr_state(_pr_payload(state="closed", merged=True))
with patch("agent.webhooks.common.get_client", return_value=fake_client):
await webhook_common.update_agent_thread_pr_state(_pr_payload(state="closed", merged=True))
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
@ -6,18 +6,19 @@ from unittest.mock import AsyncMock, patch
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
def test_parse_autofix_command() -> None:
assert webapp._parse_autofix_command("@open-swe autofix off") is True
assert webapp._parse_autofix_command("@open-swe autofix on") is False
assert webapp._parse_autofix_command("@openswe please autofix off now") is True
assert github_webhooks._parse_autofix_command("@open-swe autofix off") is True
assert github_webhooks._parse_autofix_command("@open-swe autofix on") is False
assert github_webhooks._parse_autofix_command("@openswe please autofix off now") is True
# Missing the mention -> not a command.
assert webapp._parse_autofix_command("autofix off") is None
assert github_webhooks._parse_autofix_command("autofix off") is None
# 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:
@ -28,7 +29,7 @@ def test_pr_ref_from_issue_comment() -> None:
"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"}
@ -37,19 +38,19 @@ def test_pr_ref_from_review_comment() -> None:
"repository": {"owner": {"login": "o"}, "name": "r"},
"pull_request": {"number": 9, "html_url": "https://github.com/o/r/pull/9"},
}
ref = webapp._pr_ref_from_comment_payload(payload, "pull_request_review_comment")
ref = github_webhooks._pr_ref_from_comment_payload(payload, "pull_request_review_comment")
assert ref["number"] == 9
def test_pr_ref_none_when_not_a_pr() -> None:
payload = {"repository": {"owner": {"login": "o"}, "name": "r"}, "issue": {"number": 3}}
# issue without pull_request still yields a ref (number present); url empty.
ref = webapp._pr_ref_from_comment_payload(payload, "issue_comment")
ref = github_webhooks._pr_ref_from_comment_payload(payload, "issue_comment")
assert ref["url"] == ""
def test_is_actionable_review_payload() -> None:
assert webapp._is_actionable_review_payload(
assert github_webhooks._is_actionable_review_payload(
{
"action": "submitted",
"review": {
@ -62,7 +63,7 @@ def test_is_actionable_review_payload() -> None:
"pull_request_review",
)
# Approval is not actionable.
assert not webapp._is_actionable_review_payload(
assert not github_webhooks._is_actionable_review_payload(
{
"action": "submitted",
"review": {
@ -75,7 +76,7 @@ def test_is_actionable_review_payload() -> None:
"pull_request_review",
)
# Bot author is not actionable.
assert not webapp._is_actionable_review_payload(
assert not github_webhooks._is_actionable_review_payload(
{
"action": "created",
"comment": {
@ -87,7 +88,7 @@ def test_is_actionable_review_payload() -> None:
"pull_request_review_comment",
)
# 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",
"comment": {
@ -99,7 +100,7 @@ def test_is_actionable_review_payload() -> None:
"pull_request_review_comment",
)
# Empty body is not actionable.
assert not webapp._is_actionable_review_payload(
assert not github_webhooks._is_actionable_review_payload(
{
"action": "created",
"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")
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()
kwargs = handle.await_args.kwargs
assert kwargs["repo_config"] == {"owner": "o", "name": "r"}
@ -137,7 +138,7 @@ async def test_process_github_ci_event_ignores_success() -> None:
}
handle = AsyncMock()
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()
@ -151,9 +152,13 @@ async def test_process_autofix_command_sets_flag() -> None:
setter = AsyncMock()
with (
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)
@ -166,7 +171,7 @@ async def test_autofix_review_dispatches_for_writer() -> None:
}
handle = AsyncMock(return_value="dispatched")
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()
@ -179,11 +184,11 @@ async def test_autofix_review_delegates_permission_check_to_core() -> None:
}
handle = AsyncMock(return_value="reviewer_no_write_permission")
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()
def test_ci_events_supported() -> None:
for event in ("check_run", "check_suite", "workflow_run", "status"):
assert event in webapp._SUPPORTED_GH_EVENTS
assert event in webapp._GH_CI_EVENTS
assert event in webhook_common._SUPPORTED_GH_EVENTS
assert event in webhook_common._GH_CI_EVENTS

View file

@ -12,9 +12,10 @@ from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
from agent import webapp
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_routes
def _req() -> object:
@ -207,25 +208,33 @@ def _run_comment(payload: dict, server_comment: dict | None, *, active: set[str]
with (
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(
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(
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",
return_value={"owner": "o", "name": "n"},
),
patch.object(webapp, "_is_repo_allowed", return_value=True),
patch.object(webapp, "generate_thread_id_from_confluence_comment", return_value="th-1"),
patch.object(webhook_common, "_is_repo_allowed", return_value=True),
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))
return captured
@ -278,7 +287,7 @@ def test_webhook_bot_own_comment_dropped() -> 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["scopes"] == ["READ"]
assert desc["authentication"]["type"] == "jwt"

View file

@ -1,6 +1,5 @@
from __future__ import annotations
from agent import webapp
from agent.dashboard.agent_overrides import profile_create_prs
from agent.prompt import construct_system_prompt
from agent.utils import github_comments
@ -10,6 +9,7 @@ from agent.utils.authorship import (
CollaboratorIdentity,
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}>"
@ -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"}]
)
try:
prompt = webapp.build_github_issue_prompt(
prompt = github_webhooks.build_github_issue_prompt(
{"owner": "langchain-ai", "name": "open-swe"},
42,
"12345",

View file

@ -5,12 +5,13 @@ from typing import Any
import pytest
from agent import webapp
from agent.utils import github_feedback
from agent.utils.github_feedback import (
process_github_reaction_added,
process_github_reaction_removed,
)
from agent.webhooks import common as webhook_common
from agent.webhooks import github_routes
class _FakeStore:
@ -176,9 +177,9 @@ async def test_github_webhook_ignores_reaction_event(monkeypatch: pytest.MonkeyP
payload = _reaction_payload()
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 background_tasks.tasks == []

View file

@ -9,10 +9,13 @@ import logging
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.utils import slack as slack_utils
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")
@ -61,15 +64,15 @@ def _post_slack_webhook(client: TestClient, payload: dict) -> object:
def test_generate_thread_id_from_github_issue_is_deterministic() -> None:
first = webapp.generate_thread_id_from_github_issue("12345")
second = webapp.generate_thread_id_from_github_issue("12345")
first = webhook_common.generate_thread_id_from_github_issue("12345")
second = webhook_common.generate_thread_id_from_github_issue("12345")
assert first == second
assert len(first) == 36
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"},
42,
"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"}]
)
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:
user_mappings.clear_cache()
@ -113,18 +118,22 @@ def test_auto_review_enablement_uses_dashboard_opt_in(monkeypatch) -> None:
seen["name"] = name
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 (
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
)
assert seen == {"owner": "langchain-ai", "name": "open-swe-app"}
assert (
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
)
@ -140,11 +149,11 @@ def test_github_webhook_skips_automatic_review_when_disabled(monkeypatch) -> Non
nonlocal called
called = True
monkeypatch.setattr(webapp, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
monkeypatch.setattr(webapp, "process_github_pr_ready", fake_process_github_pr_ready)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webhook_common, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
monkeypatch.setattr(github_webhooks, "process_github_pr_ready", fake_process_github_pr_ready)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"pull_request",
@ -170,10 +179,10 @@ def test_github_webhook_accepts_issue_events(monkeypatch) -> None:
called["payload"] = payload
called["event_type"] = event_type
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"issues",
@ -202,10 +211,10 @@ def test_github_webhook_ignores_issue_events_without_body_or_title_change(monkey
nonlocal called
called = True
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"issues",
@ -235,10 +244,10 @@ def test_github_webhook_accepts_issue_comment_events(monkeypatch) -> None:
called["payload"] = payload
called["event_type"] = event_type
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(github_webhooks, "process_github_issue", fake_process_github_issue)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"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:
raise AssertionError("process_github_pr_comment should not be called")
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
caplog.set_level(logging.INFO, logger=webapp.logger.name)
monkeypatch.setattr(
github_webhooks, "process_github_pr_comment", fake_process_github_pr_comment
)
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(
client,
"pull_request_review_comment",
@ -303,12 +314,14 @@ def test_github_webhook_routes_review_comment_reply_without_tag(monkeypatch) ->
return False
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(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webhook_common, "_is_repo_auto_review_enabled", fake_auto_review_enabled)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"pull_request_review_comment",
@ -341,7 +354,7 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
captured: 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]:
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:
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(
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(webapp, "fetch_pr_review_threads", fake_fetch_threads)
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_token)
monkeypatch.setattr(webhook_common, "fetch_pr_review_threads", fake_fetch_threads)
monkeypatch.setattr(webhook_common, "reconcile_findings_with_review_threads", fake_reconcile)
monkeypatch.setattr(webhook_common, "list_reviewer_findings", fake_list_findings)
monkeypatch.setattr(webhook_common, "append_finding_interaction", fake_append_interaction)
monkeypatch.setattr(webhook_common, "_store_current_reviewer_run_id", fake_store_current_run_id)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
asyncio.run(
webapp.process_github_review_finding_reply(
github_webhooks.process_github_review_finding_reply(
{
"comment": {
"id": 222,
@ -422,7 +435,7 @@ def test_process_github_review_finding_reply_dispatches_sanitized_reply_body(mon
captured: 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]:
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:
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(
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(webapp, "fetch_pr_review_threads", fake_fetch_threads)
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_token)
monkeypatch.setattr(webhook_common, "fetch_pr_review_threads", fake_fetch_threads)
monkeypatch.setattr(webhook_common, "reconcile_findings_with_review_threads", fake_reconcile)
monkeypatch.setattr(webhook_common, "list_reviewer_findings", fake_list_findings)
monkeypatch.setattr(webhook_common, "append_finding_interaction", fake_append_interaction)
monkeypatch.setattr(webhook_common, "_store_current_reviewer_run_id", fake_store_current_run_id)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
asyncio.run(
webapp.process_github_review_finding_reply(
github_webhooks.process_github_review_finding_reply(
{
"comment": {
"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:
raise AssertionError("process_github_pr_comment should not be called")
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_github_pr_comment)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(
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(
client,
"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:
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(webapp.app)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"pull_request",
@ -563,9 +578,9 @@ def test_is_docs_plz_slack_channel_matches_name(monkeypatch) -> None:
assert channel_id == "C_DOCS"
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:
@ -573,9 +588,9 @@ def test_is_docs_plz_slack_channel_matches_normalized_name(monkeypatch) -> None:
assert channel_id == "C_DOCS"
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:
@ -606,16 +621,18 @@ def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
) -> None:
raise AssertionError("docs-plz gate should not start the agent")
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
monkeypatch.setattr(webapp, "_get_slack_channel_context", fake_get_slack_channel_context)
monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_post_slack_thread_reply)
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, "_get_slack_channel_context", fake_get_slack_channel_context
)
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(
client,
{
@ -636,7 +653,7 @@ def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None:
assert captured["reply"] == {
"channel_id": "C_DOCS",
"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["repo_config"] = repo_config
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
monkeypatch.setattr(webapp, "_get_slack_channel_context", fake_get_slack_channel_context)
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
monkeypatch.setattr(webapp, "process_slack_mention", fake_process_slack_mention)
monkeypatch.setattr(
webhook_common, "_get_slack_channel_context", fake_get_slack_channel_context
)
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(
client,
{
@ -729,14 +748,14 @@ def test_slack_webhook_malformed_review_command_starts_agent(monkeypatch) -> Non
captured["event_data"] = event_data
captured["repo_config"] = repo_config
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
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(
client,
{
@ -780,21 +799,21 @@ def test_slack_webhook_non_pr_review_request_starts_agent(monkeypatch) -> None:
captured["event_data"] = event_data
captured["repo_config"] = repo_config
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
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)
monkeypatch.setattr(
webapp,
webhook_common,
"_is_repo_allowed",
lambda repo_config: (_ for _ in ()).throw(
AssertionError("Slack webhook should not gate inferred repos with allowlists")
),
)
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_slack_webhook(
client,
{
@ -836,14 +855,14 @@ def test_slack_webhook_threaded_followup_uses_parent_thread_ts(monkeypatch) -> N
captured["event_data"] = event_data
captured["repo_config"] = repo_config
monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET)
monkeypatch.setattr(webhook_common, "SLACK_BOT_USER_ID", "UBOT")
monkeypatch.setattr(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000)
monkeypatch.setattr(webapp, "get_slack_repo_config", fake_get_slack_repo_config)
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(
client,
{
@ -904,7 +923,7 @@ def test_process_github_pr_ready_creates_reviewer_run(monkeypatch) -> None:
captured["set_metadata_kwargs"] = kwargs
monkeypatch.setattr(
webapp,
webhook_common,
"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
return 1
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_github_token)
monkeypatch.setattr(
webhook_common, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata
)
monkeypatch.setattr(
webhook_common, "post_review_started_comment", fake_post_review_started_comment
)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClient())
asyncio.run(
webapp.process_github_pr_ready(
github_webhooks.process_github_pr_ready(
{
"action": "opened",
"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_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(
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,
webhook_common,
"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
return 1
monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
monkeypatch.setattr(webhook_common, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", fake_cache_github_token)
monkeypatch.setattr(
webhook_common, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata
)
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(
webapp.trigger_pr_review_from_ref(
github_webhooks.trigger_pr_review_from_ref(
GitHubPrRef(
owner="langchain-ai",
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:
captured["triggered"] = {"args": args, "kwargs": kwargs}
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context)
monkeypatch.setattr(webapp, "email_for_login", lambda login: asyncio.sleep(0, result=None))
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
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, "extract_pr_context", fake_extract_pr_context)
monkeypatch.setattr(
webhook_common, "email_for_login", lambda login: asyncio.sleep(0, result=None)
)
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(
webapp.process_github_pr_comment(
github_webhooks.process_github_pr_comment(
{
"comment": {"id": 9, "body": "@open-swe review"},
"sender": {"login": "external-user", "id": 123},
@ -1199,17 +1228,21 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
runs = _FakeRunsClient()
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(
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(
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",
lambda login: asyncio.sleep(
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(
webapp.process_github_issue(
github_webhooks.process_github_issue(
{
"issue": {
"id": 12345,
@ -1278,17 +1311,19 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
runs = _FakeRunsClient()
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(
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(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(webhook_common, "_thread_exists", fake_thread_exists)
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(
webapp,
webhook_common,
"email_for_login",
lambda login: asyncio.sleep(
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(
webapp.process_github_issue(
github_webhooks.process_github_issue(
{
"issue": {
"id": 12345,
@ -1332,11 +1367,11 @@ def test_github_webhook_routes_pr_comment_review_to_agent(monkeypatch) -> None:
captured["payload"] = payload
captured["event_type"] = event_type
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
monkeypatch.setattr(github_webhooks, "process_github_pr_comment", fake_process_pr_comment)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"issue_comment",
@ -1365,11 +1400,11 @@ def test_github_webhook_routes_pr_review_request_comment_to_agent(monkeypatch) -
captured["payload"] = payload
captured["event_type"] = event_type
monkeypatch.setattr(webapp, "process_github_pr_comment", fake_process_pr_comment)
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
monkeypatch.setattr(github_webhooks, "process_github_pr_comment", fake_process_pr_comment)
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset({"langchain-ai"}))
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"issue_comment",

View file

@ -3,7 +3,7 @@
Covers:
- (a) expired-cache reads return None / fall through to re-auth
- (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
failure when GitHub responds 401
"""
@ -198,7 +198,8 @@ def test_process_github_pr_comment_invalidates_and_reauths_on_401(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""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}
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:
return None
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context)
monkeypatch.setattr(webapp, "_get_or_resolve_thread_github_token", fake_get_or_resolve)
monkeypatch.setattr(webapp, "invalidate_cached_github_token", fake_invalidate)
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
monkeypatch.setattr(webapp, "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, "extract_pr_context", fake_extract_pr_context)
monkeypatch.setattr(webhook_common, "_get_or_resolve_thread_github_token", fake_get_or_resolve)
monkeypatch.setattr(webhook_common, "invalidate_cached_github_token", fake_invalidate)
monkeypatch.setattr(webhook_common, "react_to_github_comment", fake_react)
monkeypatch.setattr(webhook_common, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments)
monkeypatch.setattr(webhook_common, "_trigger_or_queue_run", fake_trigger_or_queue_run)
monkeypatch.setattr(
webapp,
webhook_common,
"email_for_login",
lambda login: asyncio.sleep(0, result="octo@example.com" if login == "octo" else None),
)
asyncio.run(
webapp.process_github_pr_comment(
github_webhooks.process_github_pr_comment(
{"sender": {"login": "octo", "id": 1}},
"issue_comment",
)

View file

@ -6,7 +6,7 @@ import asyncio
from typing import Any
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
@ -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:
# 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 {
"key": "PROJ-42",
"project_key": "PROJ",
@ -67,31 +67,39 @@ def _run_process(
with (
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(
jira_webhook.webapp,
webhook_common,
"fetch_jira_issue_details",
new_callable=AsyncMock,
return_value=_full_issue(),
),
patch.object(
jira_webhook.webapp,
webhook_common,
"fetch_jira_issue_comments",
new_callable=AsyncMock,
return_value=[],
),
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(
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(
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))
@ -164,22 +172,25 @@ def test_jira_omits_login_when_no_email_resolved() -> None:
def test_repo_cascade_uses_project_mapping(monkeypatch) -> None:
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:
monkeypatch.setattr(webapp, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webapp, "DEFAULT_REPO_OWNER", "langchain-ai")
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "open-swe")
assert webapp.get_repo_config_from_jira_mapping("UNKNOWN") == {
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_OWNER", "langchain-ai")
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "open-swe")
assert webhook_common.get_repo_config_from_jira_mapping("UNKNOWN") == {
"owner": "langchain-ai",
"name": "open-swe",
}
def test_repo_cascade_empty_without_default(monkeypatch) -> None:
monkeypatch.setattr(webapp, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
assert webapp.get_repo_config_from_jira_mapping("UNKNOWN") == {}
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "")
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 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:
@ -45,26 +46,32 @@ def _call(
bg = _FakeBackgroundTasks()
get_email = AsyncMock(return_value=email)
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(
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(
patch.object(webapp, "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))
patch.object(
webhook_common, "resolve_login_from_email_async", new=AsyncMock(return_value=None)
)
)
stack.enter_context(
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",
return_value={"owner": "langchain-ai", "name": "open-swe"},
)
)
stack.enter_context(patch.object(webapp, "_is_repo_allowed", return_value=True))
result = asyncio.run(webapp.jira_webhook(req, bg))
stack.enter_context(patch.object(webhook_common, "_is_repo_allowed", return_value=True))
result = asyncio.run(jira_routes.jira_webhook(req, bg))
return result, bg, get_email

View file

@ -10,7 +10,7 @@ from types import SimpleNamespace
import pytest
from agent import webapp
from agent.webhooks import common as webhook_common
_SECRET = "jira-automation-secret"
@ -18,74 +18,74 @@ _SECRET = "jira-automation-secret"
def _signed_body(secret: str, *, fresh: bool = True) -> tuple[bytes, str]:
ts_ms = datetime.now(UTC).timestamp() * 1000
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()
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return body, sig
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}
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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
assert webapp.verify_jira_secret({}) is False
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
assert webhook_common.verify_jira_secret({}) is False
def test_empty_header_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
assert webapp.verify_jira_secret({"X-Automation-Webhook-Token": ""}) is False
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
assert webhook_common.verify_jira_secret({"X-Automation-Webhook-Token": ""}) is False
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}
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) ---
def test_signature_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", False)
assert webapp.verify_jira_signature(b"{}", {}) is True
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", False)
assert webhook_common.verify_jira_signature(b"{}", {}) is True
def test_valid_signature_and_fresh_timestamp_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_SECRET", _SECRET)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_REQUIRE_SIGNATURE", True)
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_SECRET", _SECRET)
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) ---
@ -97,37 +97,37 @@ def _req(host: str | None) -> object:
def test_ip_check_is_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ())
assert webapp.verify_jira_source_ip(_req("9.9.9.9")) is True
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ())
assert webhook_common.verify_jira_source_ip(_req("9.9.9.9")) is True
def test_ip_in_allowlist_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
assert webapp.verify_jira_source_ip(_req("10.0.0.5")) is True
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
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:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
assert webapp.verify_jira_source_ip(_req("192.168.1.1")) is False
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
assert webhook_common.verify_jira_source_ip(_req("192.168.1.1")) is False
def test_ip_missing_client_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
assert webapp.verify_jira_source_ip(_req(None)) is False
monkeypatch.setattr(webhook_common, "JIRA_WEBHOOK_IP_ALLOWLIST", ("10.0.0.0/24",))
assert webhook_common.verify_jira_source_ip(_req(None)) is False
# --- Fail-closed repo allowlist (REQUIRE_REPO_ALLOWLIST) ---
def test_empty_allowlist_allows_all_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_REPOS", frozenset())
monkeypatch.setattr(webapp, "REQUIRE_REPO_ALLOWLIST", False)
assert webapp._is_repo_allowed({"owner": "anyone", "name": "anything"}) is True
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_REPOS", frozenset())
monkeypatch.setattr(webhook_common, "REQUIRE_REPO_ALLOWLIST", False)
assert webhook_common._is_repo_allowed({"owner": "anyone", "name": "anything"}) is True
def test_empty_allowlist_fails_closed_when_required(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_REPOS", frozenset())
monkeypatch.setattr(webapp, "REQUIRE_REPO_ALLOWLIST", True)
assert webapp._is_repo_allowed({"owner": "anyone", "name": "anything"}) is False
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_REPOS", frozenset())
monkeypatch.setattr(webhook_common, "REQUIRE_REPO_ALLOWLIST", True)
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 unittest.mock import AsyncMock, patch
from agent.webhooks import common as webhook_common
from agent.webhooks import linear as linear_webhook
@ -58,24 +59,26 @@ def _run_process(
return "zhen" if email == "zhen@example.com" else None
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(
linear_webhook.webapp, "generate_thread_id_from_issue", return_value="thread-1"
),
patch.object(
linear_webhook.webapp,
webhook_common,
"fetch_linear_issue_details",
new_callable=AsyncMock,
return_value=_full_issue(user_email=issue_data.get("comment_author", {}).get("email")),
),
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(
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))

View file

@ -7,7 +7,7 @@ import hmac
import json
from datetime import UTC, datetime
from agent import webapp
from agent.webhooks import common as webhook_common
_SECRET = "linear-signing-secret"
@ -22,32 +22,32 @@ def _now_ms() -> int:
def test_fresh_timestamp_accepted() -> None:
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:
stale = _now_ms() - 10 * 60 * 1000 # 10 minutes old
body = json.dumps({"type": "Comment", "webhookTimestamp": stale}).encode()
# 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:
future = _now_ms() + 10 * 60 * 1000
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:
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:
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:
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:
from agent.webapp import app
from agent.api.app import app
paths = set()
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:
from agent.webapp import app
from agent.api.app import app
paths = set()
for route in app.routes:

View file

@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
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(
@ -38,14 +39,16 @@ def _pr_payload(
def _patch_dispatch_deps(monkeypatch: pytest.MonkeyPatch, fake_client: Any) -> None:
monkeypatch.setattr(
webapp,
webhook_common,
"get_github_app_installation_token_with_expiry",
AsyncMock(return_value=("token", None)),
)
monkeypatch.setattr(webapp, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True))
monkeypatch.setattr(webapp, "cache_github_token_for_thread", MagicMock())
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
monkeypatch.setattr(
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
)
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
@ -53,10 +56,10 @@ async def test_pr_ready_non_draft_triggers_run(monkeypatch: pytest.MonkeyPatch)
fake_client = MagicMock()
fake_client.runs.create = AsyncMock()
_patch_dispatch_deps(monkeypatch, fake_client)
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
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))
await github_webhooks.process_github_pr_ready(_pr_payload(action="opened", draft=False))
fake_client.runs.create.assert_awaited_once()
_, 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.runs.create = AsyncMock()
get_token = AsyncMock(return_value=("scoped-token", "expires"))
monkeypatch.setattr(webapp, "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, "get_github_app_installation_token_with_expiry", get_token)
monkeypatch.setattr(
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
)
cache_token = MagicMock()
monkeypatch.setattr(webapp, "cache_github_token_for_thread", cache_token)
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
monkeypatch.setattr(webhook_common, "cache_github_token_for_thread", cache_token)
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", AsyncMock())
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=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])
_, 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.runs.create = AsyncMock()
get_token = AsyncMock(return_value=("full-token", "expires"))
monkeypatch.setattr(webapp, "get_github_app_installation_token_with_expiry", get_token)
monkeypatch.setattr(webapp, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True))
monkeypatch.setattr(webapp, "cache_github_token_for_thread", MagicMock())
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", AsyncMock())
monkeypatch.setattr(webapp, "get_client", lambda url: fake_client)
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
monkeypatch.setattr(webhook_common, "get_github_app_installation_token_with_expiry", get_token)
monkeypatch.setattr(
webhook_common, "_ensure_thread_exists_for_metadata", AsyncMock(return_value=True)
)
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)
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()
_, 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.runs.create = AsyncMock()
_patch_dispatch_deps(monkeypatch, fake_client)
monkeypatch.setattr(webapp, "_get_thread_metadata_safe", AsyncMock(return_value=None))
monkeypatch.setattr(webapp, "get_profile", AsyncMock(return_value=None))
monkeypatch.setattr(webapp, "get_team_settings", AsyncMock(return_value={}))
monkeypatch.setattr(webhook_common, "_get_thread_metadata_safe", AsyncMock(return_value=None))
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="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()
@ -131,10 +144,10 @@ async def test_pr_ready_for_review_skips_when_head_already_reviewed(
fake_client.runs.create = AsyncMock()
set_metadata = AsyncMock()
get_token = AsyncMock(return_value=("token", None))
monkeypatch.setattr(webapp, "get_github_app_installation_token_with_expiry", get_token)
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", set_metadata)
monkeypatch.setattr(webhook_common, "get_github_app_installation_token_with_expiry", get_token)
monkeypatch.setattr(webhook_common, "set_reviewer_thread_metadata", set_metadata)
monkeypatch.setattr(
webapp,
webhook_common,
"_get_thread_metadata_safe",
AsyncMock(
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(webapp, "get_profile", AsyncMock(return_value=None))
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="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()
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()
_patch_dispatch_deps(monkeypatch, fake_client)
monkeypatch.setattr(
webapp,
webhook_common,
"_get_thread_metadata_safe",
AsyncMock(
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(webapp, "get_team_settings", AsyncMock(return_value={}))
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="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()
_, 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"]
head_sha_writes = [
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
]
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()
_patch_dispatch_deps(monkeypatch, fake_client)
monkeypatch.setattr(
webapp,
webhook_common,
"get_profile",
AsyncMock(return_value={"login": "alice", "review_draft_prs": False}),
)
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()
@ -223,17 +240,17 @@ async def test_pr_ready_draft_user_override_on_wins_over_team_off(
fake_client.runs.create = AsyncMock()
_patch_dispatch_deps(monkeypatch, fake_client)
monkeypatch.setattr(
webapp,
webhook_common,
"get_profile",
AsyncMock(return_value={"login": "alice", "review_draft_prs": True}),
)
monkeypatch.setattr(
webapp,
webhook_common,
"get_team_settings",
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()
@ -247,15 +264,15 @@ async def test_pr_ready_draft_user_default_falls_back_to_team_on(
_patch_dispatch_deps(monkeypatch, fake_client)
# User profile exists but review_draft_prs is None — inherit team default.
monkeypatch.setattr(
webapp,
webhook_common,
"get_profile",
AsyncMock(return_value={"login": "alice", "review_draft_prs": None}),
)
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()
@ -268,14 +285,14 @@ async def test_pr_ready_draft_no_profile_falls_back_to_team_off(
fake_client.runs.create = AsyncMock()
_patch_dispatch_deps(monkeypatch, fake_client)
# 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(
webapp,
webhook_common,
"get_team_settings",
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()
@ -287,12 +304,12 @@ async def test_pr_ready_draft_no_profile_falls_back_to_team_on(
fake_client = MagicMock()
fake_client.runs.create = AsyncMock()
_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={"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()
@ -320,23 +337,23 @@ async def test_converted_to_draft_disables_watch_when_drafts_off(
with (
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={"kind": "reviewer", "watch": True},
),
patch(
"agent.webapp.get_profile",
"agent.webhooks.common.get_profile",
new_callable=AsyncMock,
return_value={"login": "alice", "review_draft_prs": False},
),
patch(
"agent.webapp.get_team_settings",
"agent.webhooks.common.get_team_settings",
new_callable=AsyncMock,
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
@ -347,23 +364,23 @@ async def test_converted_to_draft_keeps_watch_when_author_drafts_on(
fake_set = AsyncMock()
with (
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={"kind": "reviewer", "watch": True},
),
patch(
"agent.webapp.get_profile",
"agent.webhooks.common.get_profile",
new_callable=AsyncMock,
return_value={"login": "alice", "review_draft_prs": True},
),
patch(
"agent.webapp.get_team_settings",
"agent.webhooks.common.get_team_settings",
new_callable=AsyncMock,
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()
@ -374,22 +391,22 @@ async def test_converted_to_draft_keeps_watch_when_team_default_drafts_on(
fake_set = AsyncMock()
with (
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={"kind": "reviewer", "watch": True},
),
# Author inherits team default — team has drafts on.
patch(
"agent.webapp.get_profile",
"agent.webhooks.common.get_profile",
new_callable=AsyncMock,
return_value={"login": "alice", "review_draft_prs": None},
),
patch(
"agent.webapp.get_team_settings",
"agent.webhooks.common.get_team_settings",
new_callable=AsyncMock,
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()

View file

@ -9,7 +9,9 @@ import json
import pytest
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"
@ -40,14 +42,14 @@ def _install_membership_stub(monkeypatch, members: set[str]) -> dict[str, list[s
seen["calls"].append(username)
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
def _common_setup(monkeypatch, *, gate: str = "langchain-ai") -> None:
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webapp, "PUBLIC_REPO_ORG_GATE", gate)
monkeypatch.setattr(webapp, "ALLOWED_GITHUB_ORGS", frozenset())
monkeypatch.setattr(webhook_common, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
monkeypatch.setattr(webhook_common, "PUBLIC_REPO_ORG_GATE", gate)
monkeypatch.setattr(webhook_common, "ALLOWED_GITHUB_ORGS", frozenset())
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:
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(
client,
"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:
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(
client,
"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:
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(
client,
"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:
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(
client,
"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:
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(
client,
"issues",
@ -252,7 +262,7 @@ def test_review_requested_is_unsupported_before_public_repo_gate(monkeypatch) ->
_common_setup(monkeypatch)
seen = _install_membership_stub(monkeypatch, members={"insider"})
client = TestClient(webapp.app)
client = TestClient(api_app.app)
response = _post_github_webhook(
client,
"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:
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(
client,
"issue_comment",

View file

@ -103,12 +103,12 @@ class TestLinearWebhookRepoOverride:
@pytest.mark.asyncio
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 (
patch("agent.webapp.verify_linear_signature", return_value=True),
patch("agent.webhooks.common.verify_linear_signature", return_value=True),
patch(
"agent.webapp.fetch_linear_issue_details",
"agent.webhooks.common.fetch_linear_issue_details",
new_callable=AsyncMock,
return_value={
"id": "issue-456",
@ -120,8 +120,8 @@ class TestLinearWebhookRepoOverride:
"comments": {"nodes": []},
},
),
patch("agent.webapp._is_repo_allowed", return_value=True),
patch("agent.webapp.BackgroundTasks"),
patch("agent.webhooks.common._is_repo_allowed", return_value=True),
patch("agent.webhooks.common.BackgroundTasks"),
):
mock_request = AsyncMock()
mock_request.body.return_value = json.dumps(_base_payload).encode()
@ -139,7 +139,7 @@ class TestLinearWebhookRepoOverride:
@pytest.mark.asyncio
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 = {
"type": "Comment",
@ -156,9 +156,9 @@ class TestLinearWebhookRepoOverride:
}
with (
patch("agent.webapp.verify_linear_signature", return_value=True),
patch("agent.webhooks.common.verify_linear_signature", return_value=True),
patch(
"agent.webapp.fetch_linear_issue_details",
"agent.webhooks.common.fetch_linear_issue_details",
new_callable=AsyncMock,
return_value={
"id": "issue-456",
@ -170,7 +170,7 @@ class TestLinearWebhookRepoOverride:
"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.body.return_value = json.dumps(payload).encode()

View file

@ -14,7 +14,7 @@ from agent.dashboard.review_api import (
get_pr_head_sha,
reviewer_thread_id,
)
from agent.webapp import generate_reviewer_thread_id
from agent.webhooks.common import generate_reviewer_thread_id
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")
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)

View file

@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
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(
@ -46,9 +47,11 @@ async def test_push_event_skips_branch_deletion() -> None:
ref="refs/heads/feat-x", after="0000000000000000000000000000000000000000"
)
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
# function returned early on the deletion check.
@ -68,26 +71,28 @@ async def test_push_event_skips_when_thread_not_watching() -> None:
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,
),
patch(
"agent.webapp.get_github_app_installation_token",
"agent.webhooks.common.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="t",
),
patch(
"agent.webapp._fetch_open_pr_for_branch",
"agent.webhooks.common._fetch_open_pr_for_branch",
new_callable=AsyncMock,
return_value=pr,
),
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
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()
@ -107,20 +112,22 @@ async def test_push_event_skips_when_pr_diff_unchanged_since_last_review() -> No
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,
),
patch(
"agent.webapp.get_github_app_installation_token_with_expiry",
"agent.webhooks.common.get_github_app_installation_token_with_expiry",
new_callable=AsyncMock,
return_value=("t", None),
),
patch(
"agent.webapp._fetch_open_pr_for_branch",
"agent.webhooks.common._fetch_open_pr_for_branch",
new_callable=AsyncMock,
return_value=pr,
),
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={
"kind": "reviewer",
@ -129,24 +136,24 @@ async def test_push_event_skips_when_pr_diff_unchanged_since_last_review() -> No
},
),
patch(
"agent.webapp._fetch_compare_diff",
"agent.webhooks.common._fetch_compare_diff",
new_callable=AsyncMock,
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(
"agent.webapp.create_review_check_run",
"agent.webhooks.common.create_review_check_run",
new_callable=AsyncMock,
return_value=42,
) as create_check,
patch(
"agent.webapp.complete_review_check_run",
"agent.webhooks.common.complete_review_check_run",
new_callable=AsyncMock,
return_value=True,
) 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()
set_metadata.assert_awaited_once()
@ -175,25 +182,27 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
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,
),
patch(
"agent.webapp.get_github_app_installation_token",
"agent.webhooks.common.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="t",
),
patch(
"agent.webapp.get_github_app_installation_token_with_expiry",
"agent.webhooks.common.get_github_app_installation_token_with_expiry",
new_callable=AsyncMock,
return_value=("t", None),
),
patch(
"agent.webapp._fetch_open_pr_for_branch",
"agent.webhooks.common._fetch_open_pr_for_branch",
new_callable=AsyncMock,
return_value=pr,
),
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={
"kind": "reviewer",
@ -202,28 +211,28 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
},
),
patch(
"agent.webapp._fetch_compare_diff",
"agent.webhooks.common._fetch_compare_diff",
new_callable=AsyncMock,
side_effect=["old diff", "new diff"],
),
patch(
"agent.webapp._ensure_thread_exists_for_metadata",
"agent.webhooks.common._ensure_thread_exists_for_metadata",
new_callable=AsyncMock,
return_value=True,
),
patch("agent.webapp.cache_github_token_for_thread"),
patch("agent.webhooks.common.cache_github_token_for_thread"),
patch(
"agent.webapp.set_reviewer_thread_metadata",
"agent.webhooks.common.set_reviewer_thread_metadata",
new_callable=AsyncMock,
) as set_meta,
patch(
"agent.webapp.create_review_check_run",
"agent.webhooks.common.create_review_check_run",
new_callable=AsyncMock,
return_value=99,
) 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()
args, kwargs = fake_client.runs.create.await_args
@ -267,20 +276,22 @@ async def test_push_event_idempotent_when_head_unchanged() -> None:
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,
),
patch(
"agent.webapp.get_github_app_installation_token",
"agent.webhooks.common.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="t",
),
patch(
"agent.webapp._fetch_open_pr_for_branch",
"agent.webhooks.common._fetch_open_pr_for_branch",
new_callable=AsyncMock,
return_value=pr,
),
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
return_value={
"kind": "reviewer",
@ -288,17 +299,17 @@ async def test_push_event_idempotent_when_head_unchanged() -> None:
"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()
@pytest.mark.asyncio
async def test_reviewer_token_for_repo_public_scopes_by_id() -> None:
get_token = AsyncMock(return_value=("scoped", "exp"))
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
token, expires = await webapp._reviewer_token_for_repo(
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
token, expires = await webhook_common._reviewer_token_for_repo(
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=123
)
assert (token, expires) == ("scoped", "exp")
@ -308,8 +319,8 @@ async def test_reviewer_token_for_repo_public_scopes_by_id() -> None:
@pytest.mark.asyncio
async def test_reviewer_token_for_repo_public_scopes_by_name_without_id() -> None:
get_token = AsyncMock(return_value=("scoped", "exp"))
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
await webapp._reviewer_token_for_repo(
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
await webhook_common._reviewer_token_for_repo(
{"owner": "lc", "name": "repo"}, repo_private=False, repo_id=None
)
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
async def test_reviewer_token_for_repo_private_uses_full_token() -> None:
get_token = AsyncMock(return_value=("full", "exp"))
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
await webapp._reviewer_token_for_repo(
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
await webhook_common._reviewer_token_for_repo(
{"owner": "lc", "name": "repo"}, repo_private=True, repo_id=123
)
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
async def test_reviewer_token_for_repo_unknown_privacy_uses_full_token() -> None:
get_token = AsyncMock(return_value=("full", "exp"))
with patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token):
await webapp._reviewer_token_for_repo(
with patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token):
await webhook_common._reviewer_token_for_repo(
{"owner": "lc", "name": "repo"}, repo_private=None, repo_id=123
)
get_token.assert_awaited_once_with()
@ -352,27 +363,37 @@ async def test_push_event_public_repo_uses_scoped_token() -> None:
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,
),
patch("agent.webapp.get_github_app_installation_token_with_expiry", get_token),
patch("agent.webapp._fetch_open_pr_for_branch", new_callable=AsyncMock, return_value=pr),
patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token),
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,
return_value={"kind": "reviewer", "watch": True},
),
patch(
"agent.webapp._ensure_thread_exists_for_metadata",
"agent.webhooks.common._ensure_thread_exists_for_metadata",
new_callable=AsyncMock,
return_value=True,
),
patch("agent.webapp.cache_github_token_for_thread", cache_token),
patch("agent.webapp.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
patch("agent.webapp.reconcile_findings_with_review_threads", new_callable=AsyncMock),
patch("agent.webapp.set_reviewer_thread_metadata", new_callable=AsyncMock),
patch("agent.webapp.get_client", return_value=fake_client),
patch("agent.webhooks.common.cache_github_token_for_thread", cache_token),
patch(
"agent.webhooks.common.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]
),
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])
_, 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 (
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.webapp._fetch_open_pr_for_branch", new_callable=AsyncMock, return_value=pr),
patch("agent.webhooks.common.get_github_app_installation_token_with_expiry", get_token),
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,
return_value={"kind": "reviewer", "watch": True},
),
patch(
"agent.webapp._ensure_thread_exists_for_metadata",
"agent.webhooks.common._ensure_thread_exists_for_metadata",
new_callable=AsyncMock,
return_value=True,
),
patch("agent.webapp.cache_github_token_for_thread", cache_token),
patch("agent.webapp.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
patch("agent.webapp.reconcile_findings_with_review_threads", new_callable=AsyncMock),
patch("agent.webapp.set_reviewer_thread_metadata", new_callable=AsyncMock),
patch("agent.webapp.get_client", return_value=fake_client),
patch("agent.webhooks.common.cache_github_token_for_thread", cache_token),
patch(
"agent.webhooks.common.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]
),
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])]
_, kwargs = fake_client.runs.create.await_args
@ -432,16 +463,18 @@ async def test_pr_close_disables_watch() -> None:
with (
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,
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
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()
assert captured and captured[0][1]["watch"] is False
@ -455,13 +488,13 @@ async def test_pr_reopened_re_enables_watch() -> None:
with (
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
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
@ -470,11 +503,11 @@ async def test_pr_close_skips_non_reviewer_threads() -> None:
fake_set = AsyncMock()
with (
patch(
"agent.webapp._get_thread_metadata_safe",
"agent.webhooks.common._get_thread_metadata_safe",
new_callable=AsyncMock,
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()

View file

@ -2,7 +2,6 @@ import asyncio
import pytest
from agent import webapp
from agent.utils import slack as slack_utils
from agent.utils.slack import (
TRACE_REPLY_TIPS,
@ -15,7 +14,9 @@ from agent.utils.slack import (
select_slack_context_messages,
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"
_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
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(
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 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,
) -> None:
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "SLACK_REPO_OWNER", "default-owner")
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "default-repo")
monkeypatch.setattr(webapp, "get_team_default_repo", _no_team_default_repo)
monkeypatch.setattr(webhook_common, "SLACK_REPO_OWNER", "default-owner")
monkeypatch.setattr(webhook_common, "SLACK_REPO_NAME", "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"}
@ -423,13 +424,13 @@ def test_get_slack_repo_config_existing_thread_without_repo_uses_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
threads_client = _FakeThreadsClient(thread={"metadata": {}})
monkeypatch.setattr(webapp, "SLACK_REPO_OWNER", "default-owner")
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "default-repo")
monkeypatch.setattr(webapp, "get_team_default_repo", _no_team_default_repo)
monkeypatch.setattr(webhook_common, "SLACK_REPO_OWNER", "default-owner")
monkeypatch.setattr(webhook_common, "SLACK_REPO_NAME", "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 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"}}}
)
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"}
@ -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:
raise AssertionError("prefetched channel context should avoid a duplicate Slack lookup")
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webapp, "get_slack_channel_description", fail_get_slack_channel_description)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(
webhook_common, "get_slack_channel_description", fail_get_slack_channel_description
)
repo = asyncio.run(
webapp.get_slack_repo_config(
webhook_common.get_slack_repo_config(
"C123",
"1.234",
channel_context={
@ -495,14 +498,14 @@ def test_get_slack_repo_config_applies_profile_default_repo(
assert login == "mason"
return {"owner": "profile-owner", "name": "profile-repo"}
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webhook_common, "get_slack_user_info", fake_get_slack_user_info)
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"}
@ -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:
return {"owner": "team-owner", "name": "team-repo"}
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webapp, "get_team_default_repo", fake_get_team_default_repo)
monkeypatch.setattr(webapp, "SLACK_REPO_NAME", "")
monkeypatch.setattr(webapp, "DEFAULT_REPO_NAME", "")
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webhook_common, "get_team_default_repo", fake_get_team_default_repo)
monkeypatch.setattr(webhook_common, "SLACK_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"}
@ -583,12 +586,14 @@ def _setup_slack_mention_fakes(
runs = _FakeRunsClient()
threads = _FakeThreadsClientForProcess()
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webapp, "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(webhook_common, "SLACK_BOT_USERNAME", "open-swe")
monkeypatch.setattr(webhook_common, "get_slack_user_info", fake_get_slack_user_info)
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):
@ -606,13 +611,13 @@ def _setup_slack_mention_fakes(
async def fake_post_prompt(*args, **kwargs) -> None:
captured["prompt"] = {"args": args, "kwargs": kwargs}
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
monkeypatch.setattr(webhook_common, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webhook_common, "get_client", lambda url: _FakeLangGraphClientForProcess())
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webhook_common, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
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
return False
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
thread_ts = "1700000000.000100"
event_ts = "1700000000.000200"
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"channel_context": {
@ -724,13 +729,15 @@ def test_process_slack_mention_uses_vision_fallback_for_image_thread(
captured["image_url"] = image_url
return {"type": "image", "source_type": "base64", "mime_type": "image/png", "data": "abc"}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages)
monkeypatch.setattr(webapp, "resolve_agent_model_id", fake_resolve_agent_model_id)
monkeypatch.setattr(webapp, "fetch_image_block", fake_fetch_image_block)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(
webhook_common, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages
)
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(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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:
return False
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
thread_ts = "1700000000.000100"
event_ts = "1700000000.000200"
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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:
captured["run_mapping"] = {"args": args, "kwargs": kwargs}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "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, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webhook_common, "store_slack_run_mapping", fake_store_slack_run_mapping)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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:
return False
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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
return True
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
thread_ts = "1700000000.000100"
event_ts = "1700000000.000300"
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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"):
captured["prompt"] = {"user_id": user_id, "user_email": user_email, "reason": reason}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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"):
captured["prompt"] = {"reason": reason}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webapp, "has_access_token_record", fake_has_token_record)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webhook_common, "has_access_token_record", fake_has_token_record)
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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"):
captured["prompt"] = {"reason": reason}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webapp, "has_access_token_record", fake_has_token_record)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webhook_common, "has_access_token_record", fake_has_token_record)
monkeypatch.setattr(webhook_common, "_post_account_link_prompt", fake_post_prompt)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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:
owner_meta.update(kwargs)
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "upsert_agent_thread_owner_metadata", fake_upsert_owner)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "upsert_agent_thread_owner_metadata", fake_upsert_owner)
channel_context = {
"id": "C123",
@ -1083,7 +1090,7 @@ def test_process_slack_mention_mapped_user_with_token_runs_as_user(
}
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"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):
return None
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "is_bot_token_only_mode", lambda: True)
monkeypatch.setattr(webhook_common, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webhook_common, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webhook_common, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webhook_common, "is_bot_token_only_mode", lambda: True)
asyncio.run(
webapp.process_slack_mention(
slack_webhooks.process_slack_mention(
{
"channel_id": "C123",
"thread_ts": "1700000000.000100",

View file

@ -3,12 +3,13 @@ from typing import Any
import pytest
from agent import webapp
from agent.utils import slack_feedback
from agent.utils.slack_feedback import (
process_slack_reaction_added,
process_slack_reaction_removed,
)
from agent.webhooks import common as webhook_common
from agent.webhooks import slack_routes
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}
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 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
@ -235,12 +236,14 @@ async def test_slack_webhook_queues_reaction_removed(monkeypatch: pytest.MonkeyP
payload = {"type": "event_callback", "event_id": "Ev2", "event": event}
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 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
@ -249,9 +252,9 @@ async def test_slack_webhook_ignores_untracked_reaction(monkeypatch: pytest.Monk
payload = {"type": "event_callback", "event_id": "Ev3", "event": event}
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 background_tasks.tasks == []

View file

@ -3,6 +3,7 @@ from unittest.mock import AsyncMock
import pytest
from agent.webhooks import common as webhook_common
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)
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(
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(
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)
monkeypatch.setattr(webhook_common, "post_slack_thread_reply", post_reply)
await slack_webhook.process_slack_mention(
{