open-swe/agent/webhooks/linear_routes.py

169 lines
6 KiB
Python
Raw Permalink Normal View History

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.
2026-07-17 14:30:05 -04:00
"""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"}