2026-02-04 18:30:38 -08:00
""" Custom FastAPI routes for LangGraph server. """
import hashlib
import hmac
import json
import logging
import os
2026-03-04 16:43:28 -08:00
import uuid
2026-04-21 15:17:19 -04:00
from collections . abc import AsyncIterator
from contextlib import asynccontextmanager
2026-02-04 18:30:38 -08:00
from typing import Any
import httpx
from fastapi import BackgroundTasks , FastAPI , HTTPException , Request
2026-02-24 12:11:24 -08:00
from langchain_core . messages . content import create_text_block
2026-02-04 18:30:38 -08:00
from langgraph_sdk import get_client
2026-03-04 17:31:01 -08:00
from langgraph_sdk . client import LangGraphClient
2026-02-04 18:30:38 -08:00
2026-03-11 23:57:56 -07:00
from . utils . auth import (
is_bot_token_only_mode ,
persist_encrypted_github_token ,
resolve_github_token_from_email ,
)
2026-05-06 16:14:38 -07:00
from . utils . authorship import OPEN_SWE_BOT_NAME
2026-02-24 10:44:17 -08:00
from . utils . comments import get_recent_comments
2026-03-09 17:14:13 -07:00
from . utils . github_app import get_github_app_installation_token
from . utils . github_comments import (
OPEN_SWE_TAGS ,
build_pr_prompt ,
extract_pr_context ,
fetch_issue_comments ,
fetch_pr_comments_since_last_tag ,
format_github_comment_body_for_prompt ,
get_thread_id_from_branch ,
react_to_github_comment ,
sanitize_github_comment_body ,
verify_github_signature ,
)
from . utils . github_token import get_github_token_from_thread
from . utils . github_user_email_map import GITHUB_USER_EMAIL_MAP
2026-03-18 12:17:45 -07:00
from . utils . linear import post_linear_trace_comment
2026-03-09 17:14:13 -07:00
from . utils . linear_team_repo_map import LINEAR_TEAM_TO_REPO
2026-02-24 12:11:24 -08:00
from . utils . multimodal import dedupe_urls , extract_image_urls , fetch_image_block
2026-03-20 13:34:00 -07:00
from . utils . repo import extract_repo_from_text
2026-04-21 15:17:19 -04:00
from . utils . sandbox import validate_sandbox_startup_config
2026-03-04 16:43:28 -08:00
from . utils . slack import (
2026-05-06 17:14:43 -07:00
GitHubPrRef ,
2026-03-04 16:43:28 -08:00
add_slack_reaction ,
fetch_slack_thread_messages ,
format_slack_messages_for_prompt ,
get_slack_user_info ,
get_slack_user_names ,
2026-05-06 17:14:43 -07:00
looks_like_slack_pr_review_command ,
parse_slack_review_command ,
post_slack_thread_reply ,
2026-03-18 12:17:45 -07:00
post_slack_trace_reply ,
2026-04-29 17:42:27 -07:00
resolve_slack_links_in_context ,
2026-03-04 16:43:28 -08:00
select_slack_context_messages ,
strip_bot_mention ,
verify_slack_signature ,
)
2026-02-04 18:30:38 -08:00
logger = logging . getLogger ( __name__ )
2026-04-21 15:17:19 -04:00
@asynccontextmanager
async def lifespan ( _app : FastAPI ) - > AsyncIterator [ None ] :
validate_sandbox_startup_config ( )
yield
app = FastAPI ( lifespan = lifespan )
2026-02-04 18:30:38 -08:00
LINEAR_WEBHOOK_SECRET = os . environ . get ( " LINEAR_WEBHOOK_SECRET " , " " )
2026-03-09 17:14:13 -07:00
GITHUB_WEBHOOK_SECRET = os . environ . get ( " GITHUB_WEBHOOK_SECRET " , " " )
2026-03-04 16:43:28 -08:00
SLACK_SIGNING_SECRET = os . environ . get ( " SLACK_SIGNING_SECRET " , " " )
SLACK_BOT_USER_ID = os . environ . get ( " SLACK_BOT_USER_ID " , " " )
SLACK_BOT_USERNAME = os . environ . get ( " SLACK_BOT_USERNAME " , " " )
2026-03-20 13:34:00 -07:00
DEFAULT_REPO_OWNER = os . environ . get ( " DEFAULT_REPO_OWNER " , " langchain-ai " )
DEFAULT_REPO_NAME = os . environ . get ( " DEFAULT_REPO_NAME " , " langchainplus " )
SLACK_REPO_OWNER = os . environ . get ( " SLACK_REPO_OWNER " , " " ) or DEFAULT_REPO_OWNER
SLACK_REPO_NAME = os . environ . get ( " SLACK_REPO_NAME " , " " ) or DEFAULT_REPO_NAME
2026-02-04 18:30:38 -08:00
LANGGRAPH_URL = os . environ . get ( " LANGGRAPH_URL " ) or os . environ . get (
" LANGGRAPH_URL_PROD " , " http://localhost:2024 "
)
2026-03-17 13:49:32 -04:00
_AGENT_VERSION_METADATA : dict [ str , str ] = (
{ " LANGSMITH_AGENT_VERSION " : os . environ [ " LANGCHAIN_REVISION_ID " ] }
if os . environ . get ( " LANGCHAIN_REVISION_ID " )
else { }
)
2026-03-11 23:57:56 -07:00
ALLOWED_GITHUB_ORGS : frozenset [ str ] = frozenset (
org . strip ( ) . lower ( )
for org in os . environ . get ( " ALLOWED_GITHUB_ORGS " , " " ) . split ( " , " )
if org . strip ( )
)
2026-05-06 16:14:38 -07:00
ALLOWED_REVIEWER_GITHUB_ORGS : frozenset [ str ] = frozenset (
org . strip ( ) . lower ( )
for org in os . environ . get ( " ALLOWED_REVIEWER_GITHUB_ORGS " , " " ) . split ( " , " )
if org . strip ( )
)
ALLOWED_REVIEWER_GITHUB_REPOS : frozenset [ str ] = frozenset (
repo . strip ( ) . lower ( )
for repo in os . environ . get ( " ALLOWED_REVIEWER_GITHUB_REPOS " , " " ) . split ( " , " )
if repo . strip ( )
)
2026-03-11 23:57:56 -07:00
2026-02-04 18:30:38 -08:00
LINEAR_API_KEY = os . environ . get ( " LINEAR_API_KEY " , " " )
2026-03-09 17:14:13 -07:00
_GITHUB_BOT_MESSAGE_PREFIXES = (
" 🔐 **GitHub Authentication Required** " ,
" ✅ **Pull Request Created** " ,
" ✅ **Pull Request Updated** " ,
" **Pull Request Created** " ,
" **Pull Request Updated** " ,
" 🤖 **Agent Response** " ,
" ❌ **Agent Error** " ,
)
2026-02-04 18:30:38 -08:00
2026-02-13 12:04:46 -08:00
def get_repo_config_from_team_mapping (
2026-02-13 13:34:42 -08:00
team_identifier : str , project_name : str = " "
2026-02-13 13:54:05 -08:00
) - > dict [ str , str ] :
2026-03-20 13:34:00 -07:00
""" Look up repository configuration from LINEAR_TEAM_TO_REPO mapping. """
fallback = { " owner " : DEFAULT_REPO_OWNER , " name " : DEFAULT_REPO_NAME }
2026-02-13 12:04:46 -08:00
2026-02-13 13:34:42 -08:00
if not team_identifier or team_identifier not in LINEAR_TEAM_TO_REPO :
2026-03-20 13:34:00 -07:00
return fallback
2026-02-13 13:34:42 -08:00
config = LINEAR_TEAM_TO_REPO [ team_identifier ]
if " owner " in config and " name " in config :
return config
if " projects " in config and project_name :
2026-02-19 10:16:54 -08:00
project_config = config [ " projects " ] . get ( project_name )
if project_config :
return project_config
2026-02-13 13:34:42 -08:00
if " default " in config :
return config [ " default " ]
2026-03-20 13:34:00 -07:00
return fallback
2026-02-13 12:04:46 -08:00
2026-02-04 18:30:38 -08:00
async def react_to_linear_comment ( comment_id : str , emoji : str = " 👀 " ) - > bool :
""" Add an emoji reaction to a Linear comment.
Args :
comment_id : The Linear comment ID
emoji : The emoji to react with ( default : eyes 👀 )
Returns :
True if successful , False otherwise
"""
if not LINEAR_API_KEY :
return False
url = " https://api.linear.app/graphql "
mutation = """
mutation ReactionCreate ( $ commentId : String ! , $ emoji : String ! ) {
reactionCreate ( input : { commentId : $ commentId , emoji : $ emoji } ) {
success
}
}
"""
async with httpx . AsyncClient ( ) as client :
try :
response = await client . post (
url ,
headers = {
" Authorization " : LINEAR_API_KEY ,
" Content-Type " : " application/json " ,
} ,
json = {
" query " : mutation ,
" variables " : { " commentId " : comment_id , " emoji " : emoji } ,
} ,
)
response . raise_for_status ( )
result = response . json ( )
return bool ( result . get ( " data " , { } ) . get ( " reactionCreate " , { } ) . get ( " success " ) )
except Exception : # noqa: BLE001
return False
async def fetch_linear_issue_details ( issue_id : str ) - > dict [ str , Any ] | None :
""" Fetch full issue details from Linear API including description and comments.
Args :
issue_id : The Linear issue ID
Returns :
Full issue data dict , or None if fetch failed
"""
if not LINEAR_API_KEY :
return None
url = " https://api.linear.app/graphql "
query = """
query GetIssue ( $ issueId : String ! ) {
issue ( id : $ issueId ) {
id
identifier
title
description
url
2026-02-13 17:21:05 -08:00
project {
id
name
}
team {
id
name
key
}
2026-02-04 18:30:38 -08:00
comments {
nodes {
id
body
createdAt
user {
id
name
email
}
}
}
}
}
"""
async with httpx . AsyncClient ( ) as client :
try :
response = await client . post (
url ,
headers = {
" Authorization " : LINEAR_API_KEY ,
" Content-Type " : " application/json " ,
} ,
json = {
" query " : query ,
" variables " : { " issueId " : issue_id } ,
} ,
)
response . raise_for_status ( )
result = response . json ( )
return result . get ( " data " , { } ) . get ( " issue " )
except httpx . HTTPError :
return None
def generate_thread_id_from_issue ( issue_id : str ) - > str :
""" Generate a deterministic thread ID from a Linear issue ID.
Args :
issue_id : The Linear issue ID
Returns :
A UUID - formatted thread ID derived from the issue ID
"""
hash_bytes = hashlib . sha256 ( f " linear-issue: { issue_id } " . encode ( ) ) . hexdigest ( )
return (
f " { hash_bytes [ : 8 ] } - { hash_bytes [ 8 : 12 ] } - { hash_bytes [ 12 : 16 ] } - "
f " { hash_bytes [ 16 : 20 ] } - { hash_bytes [ 20 : 32 ] } "
)
2026-03-09 17:14:13 -07:00
def generate_thread_id_from_github_issue ( issue_id : str ) - > str :
""" Generate a deterministic thread ID from a GitHub issue ID. """
hash_bytes = hashlib . sha256 ( f " github-issue: { issue_id } " . encode ( ) ) . hexdigest ( )
return (
f " { hash_bytes [ : 8 ] } - { hash_bytes [ 8 : 12 ] } - { hash_bytes [ 12 : 16 ] } - "
f " { hash_bytes [ 16 : 20 ] } - { hash_bytes [ 20 : 32 ] } "
)
2026-03-04 16:43:28 -08:00
def generate_thread_id_from_slack_thread ( channel_id : str , thread_id : str ) - > str :
""" Generate a deterministic thread ID from a Slack thread identifier. """
composite = f " { channel_id } : { thread_id } "
md5_hex = hashlib . md5 ( composite . encode ( " utf-8 " ) ) . hexdigest ( )
return str ( uuid . UUID ( hex = md5_hex ) )
2026-05-06 17:14:43 -07:00
def generate_reviewer_thread_id ( owner : str , repo : str , pr_number : int ) - > str :
stable_key = f " { owner } / { repo } /pr/ { pr_number } /reviewer "
return str ( uuid . uuid5 ( uuid . NAMESPACE_URL , stable_key ) )
2026-03-04 17:31:01 -08:00
def _extract_repo_config_from_thread ( thread : dict [ str , Any ] ) - > dict [ str , str ] | None :
""" Extract repo config from persisted thread data. """
metadata = thread . get ( " metadata " )
if not isinstance ( metadata , dict ) :
return None
repo = metadata . get ( " repo " )
if isinstance ( repo , dict ) :
owner = repo . get ( " owner " )
name = repo . get ( " name " )
if isinstance ( owner , str ) and owner and isinstance ( name , str ) and name :
return { " owner " : owner , " name " : name }
owner = metadata . get ( " repo_owner " )
name = metadata . get ( " repo_name " )
if isinstance ( owner , str ) and owner and isinstance ( name , str ) and name :
return { " owner " : owner , " name " : name }
return None
def _is_not_found_error ( exc : Exception ) - > bool :
""" Best-effort check for LangGraph 404 errors. """
return getattr ( exc , " status_code " , None ) == 404
2026-05-07 10:19:53 -07:00
def _run_id_for_logging ( run : Any ) - > str :
""" Extract a run id from SDK response shapes for log messages. """
if isinstance ( run , dict ) :
run_id = run . get ( " run_id " )
else :
run_id = getattr ( run , " run_id " , None )
return run_id if isinstance ( run_id , str ) and run_id else " <unknown> "
2026-03-11 23:57:56 -07:00
def _is_repo_org_allowed ( repo_config : dict [ str , str ] ) - > bool :
""" Check if the repo owner/org is in the allowlist.
Returns True if no allowlist is configured ( empty ALLOWED_GITHUB_ORGS ) ,
or if the repo owner is in the allowlist .
"""
if not ALLOWED_GITHUB_ORGS :
return True
owner = repo_config . get ( " owner " , " " ) . lower ( )
return owner in ALLOWED_GITHUB_ORGS
2026-05-06 16:14:38 -07:00
def _is_repo_allowed_for_reviewer ( repo_config : dict [ str , str ] ) - > bool :
""" Check if a repo is allowed for reviewer-agent webhook entrypoints. """
owner = repo_config . get ( " owner " , " " ) . lower ( )
name = repo_config . get ( " name " , " " ) . lower ( )
full_name = f " { owner } / { name } " if owner and name else " "
if ALLOWED_REVIEWER_GITHUB_REPOS :
return full_name in ALLOWED_REVIEWER_GITHUB_REPOS
if not ALLOWED_REVIEWER_GITHUB_ORGS :
return True
return owner in ALLOWED_REVIEWER_GITHUB_ORGS
2026-03-04 17:31:01 -08:00
async def _upsert_slack_thread_repo_metadata (
thread_id : str , repo_config : dict [ str , str ] , langgraph_client : LangGraphClient
) - > None :
""" Persist the selected repo config on the thread metadata. """
try :
await langgraph_client . threads . update ( thread_id = thread_id , metadata = { " repo " : repo_config } )
except Exception as exc : # noqa: BLE001
if _is_not_found_error ( exc ) :
try :
await langgraph_client . threads . create (
thread_id = thread_id ,
if_exists = " do_nothing " ,
metadata = { " repo " : repo_config } ,
)
except Exception : # noqa: BLE001
logger . exception (
" Failed to create Slack thread %s while persisting repo metadata " ,
thread_id ,
)
return
logger . exception (
" Failed to persist Slack thread repo metadata for thread %s " ,
thread_id ,
)
2026-03-04 16:43:28 -08:00
async def get_slack_repo_config ( message : str , channel_id : str , thread_ts : str ) - > dict [ str , str ] :
""" Resolve repository configuration for Slack-triggered runs. """
2026-03-20 13:34:00 -07:00
default_owner = SLACK_REPO_OWNER . strip ( ) or DEFAULT_REPO_OWNER
default_name = SLACK_REPO_NAME . strip ( ) or DEFAULT_REPO_NAME
2026-03-04 17:31:01 -08:00
thread_id = generate_thread_id_from_slack_thread ( channel_id , thread_ts )
langgraph_client = get_client ( url = LANGGRAPH_URL )
2026-03-04 16:55:50 -08:00
2026-03-20 13:34:00 -07:00
repo_config = extract_repo_from_text ( message , default_owner = default_owner )
if not repo_config :
2026-03-05 13:11:10 -08:00
try :
thread = await langgraph_client . threads . get ( thread_id )
thread_repo_config = _extract_repo_config_from_thread ( thread )
if thread_repo_config :
2026-03-20 13:34:00 -07:00
repo_config = thread_repo_config
2026-03-05 13:11:10 -08:00
except Exception as exc : # noqa: BLE001
if not _is_not_found_error ( exc ) :
logger . exception (
" Failed to fetch Slack thread %s for repo resolution " ,
thread_id ,
)
2026-03-20 13:34:00 -07:00
if not repo_config :
repo_config = { " owner " : default_owner , " name " : default_name }
2026-03-05 13:11:10 -08:00
2026-03-20 13:34:00 -07:00
return repo_config
2026-03-04 16:43:28 -08:00
2026-02-04 18:30:38 -08:00
async def is_thread_active ( thread_id : str ) - > bool :
""" Check if a thread is currently active (has a running run).
Args :
thread_id : The LangGraph thread ID
Returns :
True if the thread status is " busy " , False otherwise
"""
langgraph_client = get_client ( url = LANGGRAPH_URL )
try :
logger . debug ( " Fetching thread status for %s from %s " , thread_id , LANGGRAPH_URL )
thread = await langgraph_client . threads . get ( thread_id )
status = thread . get ( " status " , " idle " )
logger . info (
" Thread %s status check: status= %s , is_busy= %s " ,
thread_id ,
status ,
status == " busy " ,
)
except Exception as e : # noqa: BLE001
logger . warning (
" Failed to get thread status for %s : %s (type: %s ) - assuming not active " ,
thread_id ,
e ,
type ( e ) . __name__ ,
)
status = " idle "
return status == " busy "
2026-03-09 17:14:13 -07:00
async def _thread_exists ( thread_id : str ) - > bool :
""" Return whether a LangGraph thread already exists. """
langgraph_client = get_client ( url = LANGGRAPH_URL )
try :
await langgraph_client . threads . get ( thread_id )
return True
except Exception as exc : # noqa: BLE001
if _is_not_found_error ( exc ) :
return False
logger . warning ( " Failed to fetch thread %s , assuming it exists " , thread_id )
return True
2026-05-06 17:14:43 -07:00
async def _ensure_thread_exists_for_metadata (
thread_id : str , langgraph_client : LangGraphClient
) - > bool :
try :
await langgraph_client . threads . create ( thread_id = thread_id , if_exists = " do_nothing " )
return True
except Exception :
logger . exception ( " Failed to ensure thread %s exists before metadata update " , thread_id )
return False
2026-02-19 14:06:06 -08:00
async def queue_message_for_thread (
thread_id : str , message_content : str | list [ dict [ str , Any ] ] | dict [ str , Any ]
) - > bool :
2026-02-04 18:30:38 -08:00
""" Queue a message for a thread that is currently active.
Stores the message in the langgraph store , namespaced to the thread .
Supports multiple queued messages by storing them as a list ( FIFO order ) .
The before_model middleware will pick them up and inject them into state .
Args :
thread_id : The LangGraph thread ID
2026-02-19 14:06:06 -08:00
message_content : The message content to queue ( text or content blocks )
2026-02-04 18:30:38 -08:00
Returns :
True if successfully queued , False otherwise
"""
langgraph_client = get_client ( url = LANGGRAPH_URL )
try :
namespace = ( " queue " , thread_id )
key = " pending_messages "
new_message = { " content " : message_content }
existing_messages : list [ dict [ str , Any ] ] = [ ]
try :
existing_item = await langgraph_client . store . get_item ( namespace , key )
if existing_item and existing_item . get ( " value " ) :
existing_messages = existing_item [ " value " ] . get ( " messages " , [ ] )
except Exception : # noqa: BLE001
logger . debug ( " No existing queued messages for thread %s " , thread_id )
existing_messages . append ( new_message )
value = { " messages " : existing_messages }
logger . info (
" Attempting to queue message for thread %s (total queued: %d ) " ,
thread_id ,
len ( existing_messages ) ,
)
await langgraph_client . store . put_item ( namespace , key , value )
logger . info ( " Successfully queued message for thread %s " , thread_id )
return True # noqa: TRY300
except Exception :
logger . exception ( " Failed to queue message for thread %s " , thread_id )
return False
async def process_linear_issue ( # noqa: PLR0912, PLR0915
issue_data : dict [ str , Any ] , repo_config : dict [ str , str ]
) - > None :
""" Process a Linear issue by creating a new LangGraph thread and run.
Args :
issue_data : The Linear issue data from webhook ( basic info only ) .
repo_config : The repo configuration with owner and name .
"""
issue_id = issue_data . get ( " id " , " " )
logger . info (
" Processing Linear issue %s for repo %s / %s " ,
issue_id ,
repo_config . get ( " owner " ) ,
repo_config . get ( " name " ) ,
)
triggering_comment_id = issue_data . get ( " triggering_comment_id " , " " )
if triggering_comment_id :
await react_to_linear_comment ( triggering_comment_id , " 👀 " )
thread_id = generate_thread_id_from_issue ( issue_id )
full_issue = await fetch_linear_issue_details ( issue_id )
if not full_issue :
full_issue = issue_data
user_email = None
user_name = None
comment_author = issue_data . get ( " comment_author " , { } )
if comment_author :
user_email = comment_author . get ( " email " )
user_name = comment_author . get ( " name " )
if not user_email :
creator = full_issue . get ( " creator " , { } )
if creator :
user_email = creator . get ( " email " )
user_name = user_name or creator . get ( " name " )
if not user_email :
assignee = full_issue . get ( " assignee " , { } )
if assignee :
user_email = assignee . get ( " email " )
user_name = user_name or assignee . get ( " name " )
2026-03-04 15:57:03 -08:00
logger . info ( " User email for issue %s : %s " , issue_id , user_email )
2026-02-04 18:30:38 -08:00
title = full_issue . get ( " title " , " No title " )
description = full_issue . get ( " description " ) or " No description "
2026-02-19 14:06:06 -08:00
image_urls : list [ str ] = [ ]
description_image_urls = extract_image_urls ( description )
if description_image_urls :
image_urls . extend ( description_image_urls )
logger . debug (
" Found %d image URL(s) in issue description " ,
len ( description_image_urls ) ,
)
2026-02-04 18:30:38 -08:00
comments = full_issue . get ( " comments " , { } ) . get ( " nodes " , [ ] )
comments_text = " "
2026-02-19 14:06:06 -08:00
triggering_comment = issue_data . get ( " triggering_comment " , " " )
triggering_comment_id = issue_data . get ( " triggering_comment_id " , " " )
2026-02-04 18:30:38 -08:00
bot_message_prefixes = (
" 🔐 **GitHub Authentication Required** " ,
" ✅ **Pull Request Created** " ,
2026-02-17 13:21:54 -08:00
" ✅ **Pull Request Updated** " ,
" **Pull Request Created** " ,
" **Pull Request Updated** " ,
2026-02-04 18:30:38 -08:00
" 🤖 **Agent Response** " ,
" ❌ **Agent Error** " ,
)
2026-02-19 14:06:06 -08:00
comment_ids : set [ str ] = set ( )
comment_id_to_index : dict [ str , int ] = { }
2026-02-04 18:30:38 -08:00
if comments :
for i , comment in enumerate ( comments ) :
2026-02-19 14:06:06 -08:00
comment_id = comment . get ( " id " , " " )
if comment_id :
comment_ids . add ( comment_id )
comment_id_to_index [ comment_id ] = i
2026-02-04 18:30:38 -08:00
relevant_comments = [ ]
2026-02-19 14:06:06 -08:00
trigger_index = None
if triggering_comment_id :
trigger_index = comment_id_to_index . get ( triggering_comment_id )
if trigger_index is not None :
relevant_comments = comments [ trigger_index : ]
logger . debug (
" Using triggering comment index %d to build relevant comments " ,
trigger_index ,
)
else :
2026-02-24 17:51:29 -08:00
relevant_comments = get_recent_comments ( comments , bot_message_prefixes )
2026-02-04 18:30:38 -08:00
if relevant_comments :
comments_text = " \n \n ## Comments: \n "
2026-02-24 17:51:29 -08:00
for comment in relevant_comments :
2026-02-25 11:19:30 -08:00
user = comment . get ( " user " ) or { }
author = user . get ( " name " , " User " )
2026-02-04 18:30:38 -08:00
body = comment . get ( " body " , " " )
2026-02-19 14:06:06 -08:00
body_image_urls = extract_image_urls ( body )
if body_image_urls :
image_urls . extend ( body_image_urls )
logger . debug (
" Found %d image URL(s) in comment by %s " ,
len ( body_image_urls ) ,
author ,
)
2026-02-04 18:30:38 -08:00
if any ( body . startswith ( prefix ) for prefix in bot_message_prefixes ) :
continue
comments_text + = f " \n ** { author } :** { body } \n "
2026-02-19 14:06:06 -08:00
if triggering_comment and triggering_comment_id not in comment_ids :
if not comments_text :
comments_text = " \n \n ## Comments: \n "
trigger_author = comment_author . get ( " name " , " Unknown " )
trigger_body = triggering_comment
trigger_image_urls = extract_image_urls ( trigger_body )
if trigger_image_urls :
image_urls . extend ( trigger_image_urls )
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 "
logger . debug (
" Appended triggering comment %s not present in issue comments list " ,
triggering_comment_id or " <missing-id> " ,
)
2026-03-03 14:34:29 -08:00
identifier = full_issue . get ( " identifier " , " " ) or issue_data . get ( " identifier " , " " )
triggered_by_line = f " ## Triggered by: { user_name } \n \n " if user_name else " "
tag_instruction = (
f " When calling linear_comment, tag @ { user_name } if you are asking them a question, need their input, or are notifying them of something important (e.g. a completed PR). For simple answers, tagging is not required. "
if user_name
else " "
)
2026-02-04 18:30:38 -08:00
prompt = (
f " Please work on the following issue: \n \n "
f " ## Title: { title } \n \n "
2026-03-03 14:34:29 -08:00
f " { triggered_by_line } "
f " ## Linear Ticket: { identifier } - Ticket ID: { issue_id } \n \n "
2026-02-04 18:30:38 -08:00
f " ## Description: \n { description } \n "
f " { comments_text } \n \n "
2026-03-03 14:34:29 -08:00
f " Please analyze this issue and implement the necessary changes. "
f " When you ' re done, commit and push your changes. { tag_instruction } "
2026-02-04 18:30:38 -08:00
)
2026-02-24 12:11:24 -08:00
content_blocks : list [ dict [ str , Any ] ] = [ create_text_block ( prompt ) ]
2026-02-19 14:06:06 -08:00
if image_urls :
2026-02-24 12:11:24 -08:00
image_urls = dedupe_urls ( image_urls )
2026-02-19 14:06:06 -08:00
logger . info ( " Preparing %d image(s) for multimodal content " , len ( image_urls ) )
logger . debug ( " Image URLs: %s " , image_urls )
async with httpx . AsyncClient ( ) as client :
for image_url in image_urls :
2026-02-24 12:11:24 -08:00
image_block = await fetch_image_block ( image_url , client )
2026-02-19 14:06:06 -08:00
if image_block :
content_blocks . append ( image_block )
logger . info ( " Built %d content block(s) for prompt " , len ( content_blocks ) )
2026-02-04 18:30:38 -08:00
2026-02-06 17:16:00 -08:00
linear_project_id = " "
linear_issue_number = " "
if identifier and " - " in identifier :
parts = identifier . split ( " - " , 1 )
linear_project_id = parts [ 0 ]
linear_issue_number = parts [ 1 ]
2026-02-04 18:30:38 -08:00
configurable : dict [ str , Any ] = {
" repo " : repo_config ,
" linear_issue " : {
" id " : issue_id ,
" title " : title ,
" url " : full_issue . get ( " url " , " " ) or issue_data . get ( " url " , " " ) ,
2026-02-06 17:16:00 -08:00
" identifier " : identifier ,
" linear_project_id " : linear_project_id ,
" linear_issue_number " : linear_issue_number ,
2026-03-03 14:34:29 -08:00
" triggering_user_name " : user_name or " " ,
2026-02-04 18:30:38 -08:00
} ,
2026-03-04 15:57:03 -08:00
" user_email " : user_email ,
" source " : " linear " ,
2026-02-04 18:30:38 -08:00
}
2026-03-04 15:57:03 -08:00
logger . info ( " Checking if thread %s is active before creating run " , thread_id )
thread_active = await is_thread_active ( thread_id )
logger . info ( " Thread %s active status: %s " , thread_id , thread_active )
2026-02-04 18:30:38 -08:00
2026-03-04 15:57:03 -08:00
if thread_active :
logger . info (
" Thread %s is active (busy), will queue message instead of creating run " ,
thread_id ,
)
2026-02-04 18:30:38 -08:00
2026-03-04 15:57:03 -08:00
queued_payload = { " text " : prompt , " image_urls " : image_urls }
queued = await queue_message_for_thread (
thread_id = thread_id ,
message_content = queued_payload ,
)
2026-02-04 18:30:38 -08:00
2026-03-04 15:57:03 -08:00
if queued :
logger . info ( " Message queued for thread %s , will be processed by middleware " , thread_id )
2026-03-18 12:17:45 -07:00
langgraph_client = get_client ( url = LANGGRAPH_URL )
runs = await langgraph_client . runs . list ( thread_id , limit = 1 )
if runs :
2026-04-23 13:46:28 -07:00
await post_linear_trace_comment ( issue_id , thread_id , triggering_comment_id )
2026-02-04 18:30:38 -08:00
else :
2026-03-04 15:57:03 -08:00
logger . error ( " Failed to queue message for thread %s " , thread_id )
2026-02-04 18:30:38 -08:00
else :
2026-03-04 15:57:03 -08:00
logger . info ( " Creating LangGraph run for thread %s " , thread_id )
langgraph_client = get_client ( url = LANGGRAPH_URL )
2026-04-23 13:46:28 -07:00
await langgraph_client . runs . create (
2026-03-04 15:57:03 -08:00
thread_id ,
" agent " ,
input = { " messages " : [ { " role " : " user " , " content " : content_blocks } ] } ,
2026-03-17 13:49:32 -04:00
config = { " configurable " : configurable , " metadata " : _AGENT_VERSION_METADATA } ,
2026-03-04 15:57:03 -08:00
if_not_exists = " create " ,
)
logger . info ( " LangGraph run created successfully for thread %s " , thread_id )
2026-04-23 13:46:28 -07:00
await post_linear_trace_comment ( issue_id , thread_id , triggering_comment_id )
2026-02-04 18:30:38 -08:00
2026-03-04 16:43:28 -08:00
async def process_slack_mention ( event_data : dict [ str , Any ] , repo_config : dict [ str , str ] ) - > None :
""" Process a Slack app mention by creating or interrupting a thread run. """
channel_id = event_data . get ( " channel_id " , " " )
thread_ts = event_data . get ( " thread_ts " , " " )
event_ts = event_data . get ( " event_ts " , " " )
user_id = event_data . get ( " user_id " , " " )
text = event_data . get ( " text " , " " )
bot_user_id = event_data . get ( " bot_user_id " , " " )
if not channel_id or not thread_ts or not event_ts :
logger . warning (
" Missing Slack event fields (channel_id= %s , thread_ts= %s , event_ts= %s ) " ,
channel_id ,
thread_ts ,
event_ts ,
)
return
reacted = await add_slack_reaction ( channel_id , event_ts , " eyes " )
if not reacted :
logger . debug (
" Unable to add eyes reaction for Slack message ts= %s in channel= %s " ,
event_ts ,
channel_id ,
)
thread_id = generate_thread_id_from_slack_thread ( channel_id , thread_ts )
user_email = None
user_name = " "
if user_id :
slack_user = await get_slack_user_info ( user_id )
if slack_user :
profile = slack_user . get ( " profile " , { } )
if isinstance ( profile , dict ) :
user_email = profile . get ( " email " )
user_name = (
profile . get ( " display_name " )
or profile . get ( " real_name " )
or slack_user . get ( " real_name " )
or slack_user . get ( " name " )
or " "
)
thread_messages = await 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 = select_slack_context_messages (
thread_messages , event_ts , bot_user_id , 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 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 = format_slack_messages_for_prompt (
context_messages ,
user_names_by_id ,
bot_user_id = bot_user_id ,
bot_username = SLACK_BOT_USERNAME ,
)
context_source = (
" the previous message where I was tagged "
if context_mode == " last_mention "
else " the beginning of the thread "
)
clean_text = (
strip_bot_mention ( text , bot_user_id , bot_username = SLACK_BOT_USERNAME )
or " (no text in mention) "
)
trigger_user = user_name or ( f " <@ { user_id } > " if user_id else " Unknown user " )
2026-04-29 17:42:27 -07:00
# Auto-resolve cross-posted Slack message links in context
resolved_links_section , image_urls_from_links = await resolve_slack_links_in_context (
context_messages , user_names_by_id
)
2026-03-04 16:43:28 -08:00
prompt = (
" You were mentioned in Slack. \n \n "
f " ## Repository \n { repo_config . get ( ' owner ' ) } / { repo_config . get ( ' name ' ) } \n \n "
f " ## Triggered by \n { trigger_user } \n \n "
f " ## Slack Thread \n - Channel: { channel_id } \n - Thread TS: { thread_ts } \n "
f " - Context starts at: { context_source } \n \n "
f " ## Conversation Context \n { context_text } \n \n "
f " ## Latest Mention Request \n { clean_text } \n \n "
2026-04-29 17:42:27 -07:00
+ ( f " { resolved_links_section } \n \n " if resolved_links_section else " " )
+ " Use `slack_thread_reply` to communicate in this Slack thread for clarifications, "
" status updates, and final summaries. Use `slack_read_thread_messages` to read any "
" Slack messages by providing channel_id and message_ts. "
2026-03-04 16:43:28 -08:00
)
content_blocks : list [ dict [ str , Any ] ] = [ create_text_block ( prompt ) ]
2026-03-20 14:34:14 -07:00
image_urls = dedupe_urls (
[ url for msg in context_messages for url in extract_image_urls ( msg . get ( " text " , " " ) ) ]
+ [
f [ " url_private " ]
for msg in context_messages
for f in msg . get ( " files " , [ ] )
if isinstance ( f , dict )
and f . get ( " mimetype " , " " ) . startswith ( " image/ " )
and f . get ( " url_private " )
]
2026-04-29 17:42:27 -07:00
+ image_urls_from_links
2026-03-20 14:34:14 -07:00
)
if image_urls :
logger . info ( " Preparing %d image(s) for Slack mention " , len ( image_urls ) )
async with httpx . AsyncClient ( ) as http_client :
for image_url in image_urls :
image_block = await fetch_image_block ( image_url , http_client )
if image_block :
content_blocks . append ( image_block )
2026-03-04 16:43:28 -08:00
configurable : dict [ str , Any ] = {
" repo " : repo_config ,
" slack_thread " : {
" channel_id " : channel_id ,
" thread_ts " : thread_ts ,
" triggering_user_id " : user_id ,
" triggering_user_name " : user_name ,
" triggering_user_email " : user_email ,
" triggering_event_ts " : event_ts ,
} ,
" user_email " : user_email ,
" source " : " slack " ,
}
langgraph_client = get_client ( url = LANGGRAPH_URL )
2026-03-04 17:31:01 -08:00
await _upsert_slack_thread_repo_metadata ( thread_id , repo_config , langgraph_client )
2026-03-19 13:59:25 -07:00
thread_active = await is_thread_active ( thread_id )
if thread_active :
logger . info (
" Thread %s is active, queuing Slack message for middleware pickup " ,
thread_id ,
)
queued_payload = { " text " : prompt , " image_urls " : [ ] }
queued = await queue_message_for_thread (
thread_id = thread_id ,
message_content = queued_payload ,
)
if queued :
logger . info ( " Slack message queued for thread %s " , thread_id )
else :
logger . error ( " Failed to queue Slack message for thread %s " , thread_id )
return
2026-05-07 10:19:53 -07:00
logger . info ( " Creating Slack LangGraph run for thread %s " , thread_id )
run = await langgraph_client . runs . create (
2026-03-04 16:43:28 -08:00
thread_id ,
" agent " ,
input = { " messages " : [ { " role " : " user " , " content " : content_blocks } ] } ,
2026-03-17 13:49:32 -04:00
config = { " configurable " : configurable , " metadata " : _AGENT_VERSION_METADATA } ,
2026-03-04 16:43:28 -08:00
if_not_exists = " create " ,
2026-05-07 10:19:53 -07:00
multitask_strategy = " enqueue " ,
)
logger . info (
" Slack LangGraph run %s created for thread %s " ,
_run_id_for_logging ( run ) ,
thread_id ,
2026-03-04 16:43:28 -08:00
)
2026-04-23 13:46:28 -07:00
await post_slack_trace_reply ( channel_id , thread_ts , thread_id )
2026-03-04 16:43:28 -08:00
2026-05-06 17:14:43 -07:00
async def process_slack_pr_review_request (
pr_ref : GitHubPrRef , channel_id : str , thread_ts : str
) - > None :
result = await trigger_pr_review_from_ref ( pr_ref , source = " slack " )
if result . get ( " success " ) :
thread_id = result . get ( " thread_id " )
if isinstance ( thread_id , str ) and thread_id :
await post_slack_trace_reply (
channel_id , thread_ts , thread_id , message = " Taking a look... "
)
return
await post_slack_thread_reply (
channel_id ,
thread_ts ,
f " Could not start review for < { pr_ref . url } | { pr_ref . owner } / { pr_ref . repo } # { pr_ref . number } >: "
f " { result . get ( ' error ' , ' unknown error ' ) } . " ,
)
2026-02-04 18:30:38 -08:00
def verify_linear_signature ( body : bytes , signature : str , secret : str ) - > bool :
""" Verify the Linear webhook signature.
Args :
body : Raw request body bytes
signature : The Linear - Signature header value
secret : The webhook signing secret
Returns :
True if signature is valid , False otherwise
"""
if not secret :
2026-03-11 23:57:56 -07:00
logger . warning ( " LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request " )
return False
2026-02-04 18:30:38 -08:00
expected = hmac . new ( secret . encode ( " utf-8 " ) , body , hashlib . sha256 ) . hexdigest ( )
return hmac . compare_digest ( expected , signature )
@app.post ( " /webhooks/linear " )
async def linear_webhook ( # noqa: PLR0911, PLR0912, PLR0915
request : Request , background_tasks : BackgroundTasks
) - > dict [ str , str ] :
""" Handle Linear webhooks.
Triggers a new LangGraph run when an issue gets the ' open-swe ' label added .
"""
logger . info ( " Received Linear webhook " )
body = await request . body ( )
signature = request . headers . get ( " Linear-Signature " , " " )
2026-03-11 23:57:56 -07:00
if not verify_linear_signature ( body , signature , LINEAR_WEBHOOK_SECRET ) :
2026-02-04 18:30:38 -08:00
logger . warning ( " Invalid webhook signature " )
raise HTTPException ( status_code = 401 , detail = " Invalid signature " )
try :
payload = json . loads ( body )
except json . JSONDecodeError :
logger . exception ( " Failed to parse webhook JSON " )
return { " status " : " error " , " message " : " Invalid JSON " }
if payload . get ( " type " ) != " Comment " :
logger . debug ( " Ignoring webhook: not a Comment event " )
return { " status " : " ignored " , " reason " : " Not a Comment event " }
action = payload . get ( " action " )
if action != " create " :
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 " ) :
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** " ,
2026-02-17 13:21:54 -08:00
" ✅ **Pull Request Updated** " ,
" **Pull Request Created** " ,
" **Pull Request Updated** " ,
2026-02-04 18:30:38 -08:00
" 🤖 **Agent Response** " ,
" ❌ **Agent Error** " ,
]
for prefix in bot_message_prefixes :
if comment_body . startswith ( prefix ) :
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 ( ) :
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 :
logger . debug ( " Ignoring webhook: no issue data in comment " )
return { " status " : " ignored " , " reason " : " No issue data in comment " }
2026-02-13 17:21:05 -08:00
# Fetch full issue details to get project info (webhook doesn't include it)
issue_id = issue . get ( " id " , " " )
full_issue = await fetch_linear_issue_details ( issue_id )
if not full_issue :
logger . warning ( " Failed to fetch full issue details, using webhook data " )
full_issue = issue
2026-03-20 13:34:00 -07:00
repo_config = extract_repo_from_text ( comment_body , default_owner = DEFAULT_REPO_OWNER )
2026-02-04 18:30:38 -08:00
2026-03-20 13:34:00 -07:00
if repo_config :
logger . debug (
" Using repo from comment body: %s / %s " ,
repo_config [ " owner " ] ,
repo_config [ " name " ] ,
)
else :
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 " "
2026-02-04 18:30:38 -08:00
2026-03-20 13:34:00 -07:00
team_identifier = team_name . strip ( ) if team_name else " "
project_key = project_name . strip ( ) if project_name else " "
2026-02-13 12:04:46 -08:00
2026-03-20 13:34:00 -07:00
repo_config = get_repo_config_from_team_mapping ( team_identifier , project_key )
logger . debug (
" Team/project lookup result " ,
extra = {
" team_name " : team_identifier ,
" project_name " : project_key ,
" repo_config " : repo_config ,
} ,
)
2026-02-13 11:56:03 -08:00
2026-03-11 23:57:56 -07:00
if not _is_repo_org_allowed ( repo_config ) :
logger . warning (
" Rejecting Linear webhook: org ' %s ' not in ALLOWED_GITHUB_ORGS " ,
repo_config . get ( " owner " ) ,
)
return { " status " : " ignored " , " reason " : " Repository org not in allowlist " }
2026-02-04 18:30:38 -08:00
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
logger . info (
" Accepted webhook for issue ' %s ' ( %s ), scheduling background task " ,
issue . get ( " title " ) ,
issue . get ( " id " ) ,
)
background_tasks . add_task ( process_linear_issue , issue , repo_config )
return {
" status " : " accepted " ,
" message " : f " Processing issue ' { issue . get ( ' title ' ) } ' for repo { repo_owner } / { repo_name } " ,
}
@app.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 " }
2026-03-04 16:43:28 -08:00
@app.post ( " /webhooks/slack " )
async def slack_webhook ( request : Request , background_tasks : 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 " , " " )
2026-03-11 23:57:56 -07:00
if not verify_slack_signature (
2026-03-04 16:43:28 -08:00
body = body ,
timestamp = timestamp ,
signature = signature ,
secret = SLACK_SIGNING_SECRET ,
) :
logger . warning ( " Invalid Slack signature " )
raise HTTPException ( status_code = 401 , detail = " Invalid signature " )
try :
payload = json . loads ( body )
except json . JSONDecodeError :
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 " ) != " app_mention " :
message_text = event . get ( " text " , " " )
has_username_mention = bool (
event . get ( " type " ) == " message "
and SLACK_BOT_USERNAME
and f " @ { SLACK_BOT_USERNAME } " in message_text
)
has_id_mention = bool (
event . get ( " type " ) == " message "
and SLACK_BOT_USER_ID
and f " <@ { 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 = 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 " }
2026-05-06 17:14:43 -07:00
clean_text = strip_bot_mention ( text , bot_user_id , bot_username = SLACK_BOT_USERNAME )
pr_ref = parse_slack_review_command ( clean_text )
if pr_ref :
if not _is_repo_allowed_for_reviewer ( { " owner " : pr_ref . owner , " name " : pr_ref . repo } ) :
return { " status " : " ignored " , " reason " : " Repository not in reviewer allowlist " }
background_tasks . add_task ( process_slack_pr_review_request , pr_ref , channel_id , thread_ts )
return { " status " : " accepted " , " message " : " Slack PR review request queued " }
if looks_like_slack_pr_review_command ( clean_text ) :
background_tasks . add_task (
post_slack_thread_reply ,
channel_id ,
thread_ts ,
" To request a PR review, use `@open-swe review https://github.com/OWNER/REPO/pull/NUMBER`. " ,
)
return { " status " : " ignored " , " reason " : " Malformed Slack PR review command " }
2026-03-04 16:43:28 -08:00
event_data = {
" channel_id " : channel_id ,
" thread_ts " : thread_ts ,
" event_ts " : event_ts ,
" user_id " : user_id ,
" text " : text ,
" bot_user_id " : bot_user_id ,
}
repo_config = await get_slack_repo_config ( text , channel_id , thread_ts )
2026-03-11 23:57:56 -07:00
if not _is_repo_org_allowed ( repo_config ) :
logger . warning (
" Rejecting Slack webhook: org ' %s ' not in ALLOWED_GITHUB_ORGS " ,
repo_config . get ( " owner " ) ,
)
return { " status " : " ignored " , " reason " : " Repository org not in allowlist " }
2026-03-04 16:43:28 -08:00
background_tasks . add_task ( process_slack_mention , event_data , repo_config )
return { " status " : " accepted " , " message " : " Slack mention queued " }
@app.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 " }
2026-02-04 18:30:38 -08:00
@app.get ( " /health " )
async def health_check ( ) - > dict [ str , str ] :
""" Health check endpoint. """
return { " status " : " healthy " }
2026-03-09 17:14:13 -07:00
_SUPPORTED_GH_EVENTS = frozenset (
2026-05-06 16:14:38 -07:00
[
" issue_comment " ,
" issues " ,
" pull_request " ,
" pull_request_review_comment " ,
" pull_request_review " ,
]
2026-03-09 17:14:13 -07:00
)
_SUPPORTED_GH_ISSUE_ACTIONS = frozenset ( [ " edited " , " opened " , " reopened " ] )
2026-05-06 16:14:38 -07:00
_SUPPORTED_GH_PULL_REQUEST_ACTIONS = frozenset ( [ " review_requested " ] )
2026-05-06 18:01:33 -07:00
_SUPPORTED_GH_COMMENT_ACTIONS = {
" issue_comment " : frozenset ( [ " created " , " edited " ] ) ,
" pull_request_review_comment " : frozenset ( [ " created " , " edited " ] ) ,
" pull_request_review " : frozenset ( [ " submitted " , " edited " ] ) ,
}
2026-03-09 17:14:13 -07:00
def _build_github_issue_comments_text ( comments : list [ dict [ str , Any ] ] ) - > str :
lines : list [ str ] = [ ]
for comment in comments :
body = comment . get ( " body " , " " )
if not body or any ( body . startswith ( prefix ) for prefix in _GITHUB_BOT_MESSAGE_PREFIXES ) :
continue
author = comment . get ( " author " , " unknown " )
formatted_body = format_github_comment_body_for_prompt ( author , body )
lines . append ( f " \n ** { author } :** \n { formatted_body } \n " )
if not lines :
return " "
return " \n \n ## Comments: \n " + " " . join ( lines )
def build_github_issue_prompt (
repo_config : dict [ str , str ] ,
issue_number : int ,
issue_id : str ,
title : str ,
body : str ,
comments : list [ dict [ str , Any ] ] ,
* ,
github_login : str ,
2026-03-11 23:57:56 -07:00
issue_author : str = " " ,
2026-03-09 17:14:13 -07:00
) - > str :
""" Build the user prompt for a GitHub issue-triggered run. """
triggered_by_line = f " ## Triggered by: { github_login } \n \n " if github_login else " "
comments_text = _build_github_issue_comments_text ( comments )
2026-03-11 23:57:56 -07:00
sanitized_title = sanitize_github_comment_body ( title )
formatted_body = format_github_comment_body_for_prompt ( issue_author or github_login , body )
2026-03-09 17:14:13 -07:00
return (
" Please work on the following GitHub issue: \n \n "
f " ## Repository: { repo_config . get ( ' owner ' ) } / { repo_config . get ( ' name ' ) } \n \n "
f " { triggered_by_line } "
f " ## GitHub Issue: # { issue_number } - Issue ID: { issue_id } \n \n "
2026-03-11 23:57:56 -07:00
f " ## Title: { sanitized_title } \n \n "
f " ## Description: \n { formatted_body } \n "
2026-03-09 17:14:13 -07:00
f " { comments_text } \n \n "
" Please analyze this issue and implement the necessary changes. "
2026-05-04 18:03:53 -07:00
" When you need to communicate on GitHub, use `GH_TOKEN=dummy gh issue comment` "
" with the issue number. "
2026-03-09 17:14:13 -07:00
)
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 { 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 = sanitize_github_comment_body ( title )
formatted_body = 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 "
f " Description: \n { formatted_body } "
)
async def _trigger_or_queue_run (
thread_id : str ,
prompt : str ,
* ,
github_login : str ,
2026-03-25 13:39:33 -07:00
github_user_id : int | None ,
2026-03-09 17:14:13 -07:00
repo_config : dict [ str , str ] ,
pr_number : int ,
) - > None :
""" Create a new agent run or queue the message if the thread is busy. """
thread_active = await is_thread_active ( thread_id )
if thread_active :
logger . info ( " Thread %s is busy, queuing GitHub PR comment message " , thread_id )
await queue_message_for_thread ( thread_id , prompt )
return
logger . info ( " Creating LangGraph run for thread %s from GitHub PR comment " , thread_id )
langgraph_client = get_client ( url = LANGGRAPH_URL )
await langgraph_client . runs . create (
thread_id ,
" agent " ,
input = { " messages " : [ { " role " : " user " , " content " : prompt } ] } ,
config = {
" configurable " : {
" source " : " github " ,
" github_login " : github_login ,
2026-03-25 13:39:33 -07:00
" github_user_id " : github_user_id ,
2026-03-09 17:14:13 -07:00
" repo " : repo_config ,
" pr_number " : pr_number ,
2026-03-17 13:49:32 -04:00
} ,
" metadata " : _AGENT_VERSION_METADATA ,
2026-03-09 17:14:13 -07:00
} ,
if_not_exists = " create " ,
)
logger . info ( " LangGraph run created for thread %s from GitHub PR comment " , thread_id )
2026-05-06 16:14:38 -07:00
def _is_open_swe_reviewer_request ( payload : dict [ str , Any ] ) - > bool :
reviewer = payload . get ( " requested_reviewer " ) or { }
login = reviewer . get ( " login " , " " ) if isinstance ( reviewer , dict ) else " "
return login . lower ( ) == OPEN_SWE_BOT_NAME . lower ( )
def build_github_pr_review_prompt (
repo_config : dict [ str , str ] ,
pr_number : int ,
pr_url : str ,
base_sha : str ,
head_sha : str ,
) - > str :
""" Build the user prompt for a reviewer-agent run. """
return (
" Please review this GitHub pull request. \n \n "
f " ## Repository: { repo_config . get ( ' owner ' ) } / { repo_config . get ( ' name ' ) } \n \n "
f " ## Pull Request: { pr_url } \n \n "
f " ## PR Number: { pr_number } \n \n "
f " ## Base SHA: { base_sha } \n \n "
f " ## Head SHA: { head_sha } \n \n "
" Submit findings as inline GitHub review comments. If there are no real issues, "
" submit no comments. "
)
2026-05-06 17:14:43 -07:00
async def fetch_github_pr_metadata ( pr_ref : GitHubPrRef , * , token : str ) - > dict [ str , Any ] | None :
headers = {
" Accept " : " application/vnd.github+json " ,
" Authorization " : f " Bearer { token } " ,
" X-GitHub-Api-Version " : " 2022-11-28 " ,
}
async with httpx . AsyncClient ( ) as http_client :
try :
response = await http_client . get (
f " https://api.github.com/repos/ { pr_ref . owner } / { pr_ref . repo } /pulls/ { pr_ref . number } " ,
headers = headers ,
)
response . raise_for_status ( )
except httpx . HTTPError :
logger . exception (
" Failed to fetch PR metadata for %s / %s # %s " ,
pr_ref . owner ,
pr_ref . repo ,
pr_ref . number ,
)
return None
data = response . json ( )
return data if isinstance ( data , dict ) else None
async def trigger_pr_review_from_ref (
pr_ref : GitHubPrRef ,
* ,
source : str ,
github_login : str = " " ,
github_user_id : int | None = None ,
) - > dict [ str , Any ] :
repo_config = { " owner " : pr_ref . owner , " name " : pr_ref . repo }
if not _is_repo_allowed_for_reviewer ( repo_config ) :
return { " success " : False , " error " : " Repository not allowed for reviewer " }
app_token = await get_github_app_installation_token ( )
if not app_token :
logger . warning ( " No GitHub App token available for PR reviewer request " )
return { " success " : False , " error " : " No GitHub App token available " }
pr_metadata = await fetch_github_pr_metadata ( pr_ref , token = app_token )
if not pr_metadata :
return { " success " : False , " error " : " Could not fetch pull request metadata " }
base_sha = pr_metadata . get ( " base " , { } ) . get ( " sha " , " " )
head = pr_metadata . get ( " head " , { } )
head_sha = head . get ( " sha " , " " )
branch_name = head . get ( " ref " , " " )
pr_url = pr_metadata . get ( " html_url " , " " ) or pr_ref . url
if not base_sha or not head_sha :
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 = generate_reviewer_thread_id ( pr_ref . owner , pr_ref . repo , pr_ref . number )
langgraph_client = get_client ( url = LANGGRAPH_URL )
if not await _ensure_thread_exists_for_metadata ( thread_id , langgraph_client ) :
return { " success " : False , " error " : " Could not create reviewer thread " }
try :
await persist_encrypted_github_token ( thread_id , app_token )
except Exception :
logger . warning ( " Could not persist bot token for reviewer thread %s " , thread_id )
return { " success " : False , " error " : " Could not persist reviewer token " }
prompt = build_github_pr_review_prompt ( repo_config , pr_ref . number , pr_url , base_sha , head_sha )
configurable : dict [ str , Any ] = {
" source " : source ,
" github_login " : github_login ,
" github_user_id " : github_user_id ,
" repo " : repo_config ,
" pr_number " : pr_ref . number ,
" review_requested " : True ,
}
if branch_name :
configurable [ " branch_name " ] = branch_name
thread_active = await is_thread_active ( thread_id )
if thread_active :
logger . info ( " Reviewer thread %s is busy, queuing PR review request " , thread_id )
queued = await queue_message_for_thread ( thread_id , prompt )
return { " success " : queued , " queued " : queued , " thread_id " : thread_id , " pr_url " : pr_url }
logger . info ( " Creating reviewer run for thread %s from %s PR review request " , thread_id , source )
await langgraph_client . runs . create (
thread_id ,
" reviewer " ,
input = { " messages " : [ { " role " : " user " , " content " : prompt } ] } ,
config = { " configurable " : configurable , " metadata " : _AGENT_VERSION_METADATA } ,
if_not_exists = " create " ,
)
return { " success " : True , " queued " : False , " thread_id " : thread_id , " pr_url " : pr_url }
2026-05-06 16:14:38 -07:00
async def process_github_pr_review_request ( payload : dict [ str , Any ] ) - > None :
""" Trigger the reviewer agent when the Open SWE bot is requested on a PR. """
repo = payload . get ( " repository " , { } )
pull_request = payload . get ( " pull_request " , { } )
repo_config = {
" owner " : repo . get ( " owner " , { } ) . get ( " login " , " " ) ,
" name " : repo . get ( " name " , " " ) ,
}
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 " , " " )
base_sha = pull_request . get ( " base " , { } ) . get ( " sha " , " " )
head_sha = pull_request . get ( " head " , { } ) . get ( " sha " , " " )
github_login = payload . get ( " sender " , { } ) . get ( " login " , " " )
github_user_id = payload . get ( " sender " , { } ) . get ( " id " )
if not pr_number or not pr_url or not base_sha or not head_sha :
logger . warning ( " Missing PR review request context, skipping reviewer run " )
return
2026-05-06 17:14:43 -07:00
thread_id = generate_reviewer_thread_id (
repo_config . get ( " owner " , " " ) , repo_config . get ( " name " , " " ) , pr_number
)
2026-05-06 16:14:38 -07:00
app_token = await get_github_app_installation_token ( )
if not app_token :
logger . warning ( " No GitHub App token available for PR reviewer request " )
return
2026-05-06 17:14:43 -07:00
langgraph_client = get_client ( url = LANGGRAPH_URL )
if not await _ensure_thread_exists_for_metadata ( thread_id , langgraph_client ) :
return
2026-05-06 16:14:38 -07:00
try :
await persist_encrypted_github_token ( thread_id , app_token )
except Exception :
logger . warning ( " Could not persist bot token for reviewer thread %s " , thread_id )
return
prompt = build_github_pr_review_prompt ( repo_config , pr_number , pr_url , base_sha , head_sha )
configurable : dict [ str , Any ] = {
" source " : " github " ,
" github_login " : github_login ,
" github_user_id " : github_user_id ,
" repo " : repo_config ,
" pr_number " : pr_number ,
" review_requested " : True ,
}
if branch_name :
configurable [ " branch_name " ] = branch_name
thread_active = await is_thread_active ( thread_id )
if thread_active :
logger . info ( " Reviewer thread %s is busy, queuing PR review request " , thread_id )
await queue_message_for_thread ( thread_id , prompt )
return
logger . info ( " Creating reviewer run for thread %s from GitHub PR review request " , thread_id )
await langgraph_client . runs . create (
thread_id ,
" reviewer " ,
input = { " messages " : [ { " role " : " user " , " content " : prompt } ] } ,
config = { " configurable " : configurable , " metadata " : _AGENT_VERSION_METADATA } ,
if_not_exists = " create " ,
)
logger . info ( " Reviewer run created for thread %s from GitHub PR review request " , thread_id )
2026-03-09 17:14:13 -07:00
async def _get_or_resolve_thread_github_token ( thread_id : str , email : str ) - > str | None :
2026-03-11 23:57:56 -07:00
""" Resolve and persist a GitHub token for a thread when available.
In bot - token - only mode , returns a fresh GitHub App installation token
instead of resolving per - user OAuth tokens .
"""
if is_bot_token_only_mode ( ) :
bot_token = await get_github_app_installation_token ( )
if bot_token :
try :
await persist_encrypted_github_token ( thread_id , bot_token )
except Exception :
logger . warning ( " Could not persist bot token for thread %s " , thread_id )
return bot_token
logger . warning ( " Bot-token-only mode but GitHub App token unavailable " )
return None
2026-03-09 17:14:13 -07:00
github_token , _encrypted_token = await get_github_token_from_thread ( thread_id )
if github_token :
return github_token
auth_result = await resolve_github_token_from_email ( email )
github_token = auth_result . get ( " token " )
if not github_token :
return None
try :
await persist_encrypted_github_token ( thread_id , github_token )
except Exception :
logger . warning ( " Could not persist GitHub token for thread %s " , thread_id )
return github_token
async def process_github_pr_comment ( payload : dict [ str , Any ] , event_type : str ) - > None :
""" Process a GitHub PR comment that tagged @open-swe.
Retrieves the existing thread token , reacts with 👀 , fetches all comments
since the last @open - swe tag , then creates or queues a new run .
Args :
payload : The parsed GitHub webhook payload .
event_type : One of ' issue_comment ' , ' pull_request_review_comment ' ,
' pull_request_review ' .
"""
(
repo_config ,
pr_number ,
branch_name ,
github_login ,
pr_url ,
comment_id ,
node_id ,
) = await extract_pr_context ( payload , event_type )
2026-03-25 13:39:33 -07:00
github_user_id = payload . get ( " sender " , { } ) . get ( " id " )
2026-03-09 17:14:13 -07:00
logger . info (
" Processing GitHub PR comment: event= %s , pr= %s , branch= %s " ,
event_type ,
pr_number ,
branch_name ,
)
thread_id = get_thread_id_from_branch ( branch_name ) if branch_name else None
if not thread_id :
2026-03-20 10:53:33 -07:00
if not pr_number :
logger . warning (
" Could not determine thread_id for branch ' %s ' (no pr_number), skipping " ,
branch_name ,
)
return
owner = repo_config . get ( " owner " , " " )
name = repo_config . get ( " name " , " " )
stable_key = f " { owner } / { name } /pr/ { pr_number } "
thread_id = str ( uuid . uuid5 ( uuid . NAMESPACE_URL , stable_key ) )
logger . info ( " Generated thread_id %s for non-open-swe branch ' %s ' " , thread_id , branch_name )
langgraph_client = get_client ( url = LANGGRAPH_URL )
try :
await langgraph_client . threads . update ( thread_id , metadata = { " branch_name " : branch_name } )
except Exception as exc : # noqa: BLE001
if _is_not_found_error ( exc ) :
await langgraph_client . threads . create (
thread_id = thread_id ,
if_exists = " do_nothing " ,
metadata = { " branch_name " : branch_name } ,
)
else :
logger . warning ( " Failed to persist branch_name metadata for thread %s " , thread_id )
2026-03-09 17:14:13 -07:00
email = GITHUB_USER_EMAIL_MAP . get ( github_login , " " )
if not email :
logger . warning ( " No email mapping for GitHub user ' %s ' , skipping " , github_login )
return
github_token = await _get_or_resolve_thread_github_token ( thread_id , email )
if not github_token :
logger . warning ( " No GitHub token for thread %s , skipping " , thread_id )
return
if comment_id :
await react_to_github_comment (
repo_config ,
comment_id ,
event_type = event_type ,
token = github_token ,
pull_number = pr_number ,
node_id = node_id ,
)
if not pr_number :
logger . warning ( " No PR number found in payload, skipping " )
return
comments = await fetch_pr_comments_since_last_tag ( repo_config , pr_number , token = github_token )
if not comments :
logger . info ( " No comments found since last @open-swe tag for PR %s " , pr_number )
return
feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] (#1159)
* feat: authenticate git operations via sandbox proxy instead of credential files
* feat: authenticate git operations via sandbox proxy instead of credential files
* feat: authenticate git operations via sandbox proxy instead of credential files
* removing logger.info
* formatting and linting
* fix: resolve lint errors in server.py (imports, unused vars, undefined names)
* feat: use opaque proxy headers for GitHub auth in sandbox
* linting formatting and test changes
* linting
* Delete .claude directory
* Delete tests/evals directory
* fix: address PR review — guard missing tokens, quote shell paths, add proxy auth tests
* fix: restore authorship, branch_name support, and installation token for PR creation
* linitng
* fix: move installation token fetch before commit, clean up dead proxy validation code
* feat: stop auto-cloning and let agent manage repo setup [closes OPE-21]
* feat: stop auto-cloning and let agent manage repo setup [closes OPE-21]
* fix: address review feedback — restore agents_md, add git user config, lint fixes
* fix: drop github_token arg from sandbox creation, use generic create_sandbox factory with langsmith-only proxy config
* fix: use _get_langsmith_api_key() for prod key fallback, warn when API key missing for proxy config
* linting
* linting
* feat: add installation token auth to list_repos GitHub API call
* agents.md update
* linting
* fix: address PR review feedback — shell precedence bug in prompt, remove dead code
* linting
* Apply suggestion from @bracesproul
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
* Apply suggestion from @bracesproul
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
* fix: address PR review feedback — restore {working_dir} in prompt, remove clone code block
* fix:Extract check_or_recreate_sandbox utility from inline sandbox health check
* fix: address PR review feedback — async list_repos, restore template name, fix prompt colon
* fix: resolve merge conflicts with main, adopt deepagents v0.5.0a4 LangSmithSandbox
* linting
* yogesh/ope-21-stop-auto-cloning
* Update agent/tools/list_repos.py
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
* Update agent/prompt.py
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
* feat: address PR review — list_repos uses GitHub API only, PR trigger includes org/repo
* linting
* feat: address PR review feedback — list_repos pagination, simpler return, sandbox health check
* feat: support listing repos for personal user accounts via is_organization flag
---------
Co-authored-by: Brace Sproul <braceasproul@gmail.com>
2026-04-10 17:04:55 -07:00
prompt = build_pr_prompt ( comments , pr_url , repo_config = repo_config )
2026-03-09 17:14:13 -07:00
await _trigger_or_queue_run (
thread_id ,
prompt ,
github_login = github_login ,
2026-03-25 13:39:33 -07:00
github_user_id = github_user_id ,
2026-03-09 17:14:13 -07:00
repo_config = repo_config ,
pr_number = pr_number ,
)
async def process_github_issue ( payload : dict [ str , Any ] , event_type : str ) - > None :
""" Process a GitHub issue or issue comment that tagged @open-swe. """
issue = payload . get ( " issue " , { } )
repo = payload . get ( " repository " , { } )
repo_config = {
" owner " : repo . get ( " owner " , { } ) . get ( " login " , " " ) ,
" name " : repo . get ( " name " , " " ) ,
}
issue_id = str ( issue . get ( " id " , " " ) )
issue_number = issue . get ( " number " )
github_login = payload . get ( " sender " , { } ) . get ( " login " , " " )
2026-03-25 13:39:33 -07:00
github_user_id = payload . get ( " sender " , { } ) . get ( " id " )
2026-03-09 17:14:13 -07:00
issue_url = issue . get ( " html_url " , " " ) or issue . get ( " url " , " " )
title = issue . get ( " title " , " No title " )
description = issue . get ( " body " ) or " No description "
2026-03-11 23:57:56 -07:00
issue_author = issue . get ( " user " , { } ) . get ( " login " , " " )
2026-03-09 17:14:13 -07:00
logger . info (
" Processing GitHub issue: event= %s , issue= %s , repo= %s / %s " ,
event_type ,
issue_number ,
repo_config . get ( " owner " ) ,
repo_config . get ( " name " ) ,
)
if not issue_id or not issue_number :
logger . warning ( " Missing GitHub issue id/number, skipping " )
return
email = GITHUB_USER_EMAIL_MAP . get ( github_login , " " )
if not email :
logger . warning ( " No email mapping for GitHub user ' %s ' , skipping " , github_login )
return
thread_id = generate_thread_id_from_github_issue ( issue_id )
existing_thread = await _thread_exists ( thread_id )
github_token = await _get_or_resolve_thread_github_token ( thread_id , email )
app_token = await 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 :
logger . warning ( " No GitHub token available to react to issue comment %s " , comment_id )
else :
reacted = await react_to_github_comment (
repo_config ,
comment_id ,
event_type = " issue_comment " ,
token = reaction_token ,
)
if not reacted :
logger . warning ( " Failed to react to GitHub issue comment %s " , comment_id )
if existing_thread :
if event_type == " issue_comment " :
prompt = build_github_issue_followup_prompt (
comment . get ( " user " , { } ) . get ( " login " , github_login ) or github_login ,
comment . get ( " body " , " " ) ,
)
else :
prompt = build_github_issue_update_prompt ( github_login , title , description )
else :
comments = await 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 ) :
comments . append (
{
" body " : comment . get ( " body " , " " ) ,
" author " : comment . get ( " user " , { } ) . get ( " login " , " unknown " ) ,
" created_at " : comment . get ( " created_at " , " " ) ,
" comment_id " : comment_id ,
}
)
comments . sort ( key = lambda item : item . get ( " created_at " , " " ) )
prompt = build_github_issue_prompt (
repo_config ,
issue_number ,
issue_id ,
title ,
description ,
comments ,
github_login = github_login ,
2026-03-11 23:57:56 -07:00
issue_author = issue_author ,
2026-03-09 17:14:13 -07:00
)
configurable : dict [ str , Any ] = {
" source " : " github " ,
" github_login " : github_login ,
2026-03-25 13:39:33 -07:00
" github_user_id " : github_user_id ,
2026-03-09 17:14:13 -07:00
" repo " : repo_config ,
" github_issue " : {
" id " : issue_id ,
" number " : issue_number ,
" title " : title ,
" url " : issue_url ,
} ,
}
thread_active = await is_thread_active ( thread_id )
if thread_active :
logger . info ( " Thread %s is busy, queuing GitHub issue message " , thread_id )
await queue_message_for_thread ( thread_id , prompt )
return
logger . info ( " Creating LangGraph run for thread %s from GitHub issue " , thread_id )
langgraph_client = get_client ( url = LANGGRAPH_URL )
await langgraph_client . runs . create (
thread_id ,
" agent " ,
input = { " messages " : [ { " role " : " user " , " content " : prompt } ] } ,
2026-03-17 13:49:32 -04:00
config = { " configurable " : configurable , " metadata " : _AGENT_VERSION_METADATA } ,
2026-03-09 17:14:13 -07:00
if_not_exists = " create " ,
)
logger . info ( " LangGraph run created for thread %s from GitHub issue " , thread_id )
@app.post ( " /webhooks/github " )
async def github_webhook ( request : Request , background_tasks : 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 verify_github_signature ( body , signature , secret = GITHUB_WEBHOOK_SECRET ) :
logger . warning ( " Invalid GitHub webhook signature " )
raise HTTPException ( status_code = 401 , detail = " Invalid signature " )
event_type = request . headers . get ( " X-GitHub-Event " , " " )
if event_type not in _SUPPORTED_GH_EVENTS :
logger . info ( " Ignoring unsupported GitHub event type: %s " , event_type )
return { " status " : " ignored " , " reason " : f " Unsupported event type: { event_type } " }
try :
payload = json . loads ( body )
except json . JSONDecodeError :
logger . exception ( " Failed to parse GitHub webhook JSON " )
return { " status " : " error " , " message " : " Invalid JSON " }
2026-03-11 23:57:56 -07:00
webhook_repo = payload . get ( " repository " , { } )
webhook_repo_config = {
" owner " : webhook_repo . get ( " owner " , { } ) . get ( " login " , " " ) ,
" name " : webhook_repo . get ( " name " , " " ) ,
}
2026-05-06 16:14:38 -07:00
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 _SUPPORTED_GH_PULL_REQUEST_ACTIONS :
logger . info ( " Ignoring unsupported GitHub pull_request action: %s " , action )
return {
" status " : " ignored " ,
" reason " : f " Unsupported GitHub pull_request action: { action } " ,
}
if not _is_open_swe_reviewer_request ( payload ) :
logger . info ( " Ignoring PR review request for a different reviewer " )
return { " status " : " ignored " , " reason " : " Review request is not for open-swe bot " }
if not _is_repo_allowed_for_reviewer ( webhook_repo_config ) :
logger . warning (
" Rejecting GitHub reviewer webhook: repo ' %s / %s ' failed reviewer allowlist " ,
webhook_repo_config . get ( " owner " ) ,
webhook_repo_config . get ( " name " ) ,
)
if ALLOWED_REVIEWER_GITHUB_REPOS :
reason = " Repository not in allowlist "
else :
reason = " Repository org not in allowlist "
return { " status " : " ignored " , " reason " : reason }
logger . info ( " Accepted GitHub PR review request webhook, scheduling reviewer task " )
background_tasks . add_task ( process_github_pr_review_request , payload )
return { " status " : " accepted " , " message " : " Processing GitHub PR review request " }
2026-03-11 23:57:56 -07:00
if not _is_repo_org_allowed ( webhook_repo_config ) :
logger . warning (
" Rejecting GitHub webhook: org ' %s ' not in ALLOWED_GITHUB_ORGS " ,
webhook_repo_config . get ( " owner " ) ,
)
return { " status " : " ignored " , " reason " : " Repository org not in allowlist " }
2026-03-09 17:14:13 -07:00
if is_issue_event :
action = payload . get ( " action " , " " )
if action not in _SUPPORTED_GH_ISSUE_ACTIONS :
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 " ) ) :
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 OPEN_SWE_TAGS ) :
logger . info ( " Ignoring issue that does not mention @openswe or @open-swe " )
return { " status " : " ignored " , " reason " : " Issue does not mention @openswe or @open-swe " }
logger . info ( " Accepted GitHub issue webhook, scheduling background task " )
background_tasks . add_task ( process_github_issue , payload , event_type )
return { " status " : " accepted " , " message " : " Processing GitHub issue event " }
2026-05-06 18:01:33 -07:00
action = payload . get ( " action " , " " )
supported_comment_actions = _SUPPORTED_GH_COMMENT_ACTIONS . get ( event_type )
if supported_comment_actions is None :
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 :
logger . debug ( " Ignoring unsupported GitHub %s action: %s " , event_type , action )
return { " status " : " ignored " , " reason " : f " Unsupported GitHub { event_type } action: { action } " }
2026-03-09 17:14:13 -07:00
comment = payload . get ( " comment " ) or payload . get ( " review " , { } )
comment_body = ( comment . get ( " body " ) or " " ) if comment else " "
if not any ( tag in comment_body . lower ( ) for tag in OPEN_SWE_TAGS ) :
2026-05-06 18:01:33 -07:00
logger . debug (
" Ignoring GitHub %s %s that does not mention @openswe or @open-swe " ,
event_type ,
f " action= { action } " if action else " " ,
)
2026-03-09 17:14:13 -07:00
return { " status " : " ignored " , " reason " : " Comment does not mention @openswe or @open-swe " }
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 ( process_github_pr_comment , payload , event_type )
return { " status " : " accepted " , " message " : f " Processing { event_type } event " }
if is_issue_comment :
background_tasks . add_task ( process_github_issue , payload , event_type )
return { " status " : " accepted " , " message " : " Processing GitHub issue comment event " }
logger . info ( " Ignoring unsupported GitHub payload shape for event= %s " , event_type )
return { " status " : " ignored " , " reason " : f " Unsupported payload for event type: { event_type } " }