mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
chore: resolve github token in get_agent and read from thread metadata (#1002)
* chore: resolve github token in get_agent and read from thread metadata * feat: add linear_comment tool and fix middleware PR response * refactor: simplify github token resolution flow * 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> * refactor: simplify auth helpers and token flow * linting * open_pr changes * Update open_pr.py * refactor: centralize github token helpers and config lookup * Apply suggestions from code review --------- Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
parent
3dee480e82
commit
b5951413d7
6 changed files with 346 additions and 258 deletions
|
|
@ -114,7 +114,8 @@ CORE_BEHAVIOR_SECTION = """---
|
|||
### Core Behavior
|
||||
|
||||
- **Persistence:** Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete.
|
||||
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure."""
|
||||
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure.
|
||||
- **Autonomy:** Never ask the user for permission mid-task. Run linters, fix errors, and call `commit_and_open_pr` without waiting for confirmation."""
|
||||
|
||||
|
||||
DEPENDENCY_SECTION = """---
|
||||
|
|
@ -200,6 +201,8 @@ When you have completed your implementation, follow these steps in order:
|
|||
|
||||
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
|
||||
|
||||
**IMPORTANT: Never ask the user for permission or confirmation before calling `commit_and_open_pr`. Do not say "if you want, I can proceed" or "shall I open the PR?". When your implementation is done and checks pass, call the tool immediately and autonomously.**
|
||||
|
||||
4. **Comment on the Linear ticket** via `linear_comment` immediately after `commit_and_open_pr` succeeds. Include:
|
||||
- A brief summary of what was done
|
||||
- The PR link returned by `commit_and_open_pr`
|
||||
|
|
@ -214,7 +217,7 @@ When you have completed your implementation, follow these steps in order:
|
|||
- <change 2>
|
||||
```
|
||||
|
||||
Always call `commit_and_open_pr` followed by `linear_comment` once implementation is complete and code quality checks pass."""
|
||||
Always call the `commit_and_open_pr` tool followed by the `linear_comment` tool once implementation is complete and code quality checks pass."""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ from deepagents import create_deep_agent
|
|||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
from langsmith.sandbox import SandboxClientError
|
||||
|
||||
from .encryption import decrypt_token
|
||||
from .integrations.langsmith import create_langsmith_sandbox
|
||||
from .middleware import (
|
||||
ToolErrorMiddleware,
|
||||
|
|
@ -34,6 +33,7 @@ from .middleware import (
|
|||
)
|
||||
from .prompt import construct_system_prompt
|
||||
from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment
|
||||
from .utils.auth import save_encrypted_token_from_email
|
||||
from .utils.model import make_model
|
||||
|
||||
client = get_client()
|
||||
|
|
@ -234,7 +234,6 @@ DEFAULT_RECURSION_LIMIT = 1_000
|
|||
async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||
"""Get or create an agent with a sandbox for the given thread."""
|
||||
thread_id = config["configurable"].get("thread_id", None)
|
||||
logger.info("get_agent called for thread %s", thread_id)
|
||||
|
||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||||
|
||||
|
|
@ -242,10 +241,6 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
|||
repo_owner = repo_config.get("owner")
|
||||
repo_name = repo_config.get("name")
|
||||
|
||||
encrypted_token = config["configurable"].get("github_token_encrypted")
|
||||
if encrypted_token:
|
||||
github_token = decrypt_token(encrypted_token)
|
||||
|
||||
if thread_id is None or not graph_loaded_for_execution(config):
|
||||
logger.info("No thread_id or not for execution, returning agent without sandbox")
|
||||
return create_deep_agent(
|
||||
|
|
@ -253,6 +248,21 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
|||
tools=[],
|
||||
).with_config(config)
|
||||
|
||||
# --- GitHub token resolution ---
|
||||
user_email = config["configurable"].get("user_email")
|
||||
source = config["configurable"].get("source")
|
||||
if not source:
|
||||
logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id)
|
||||
msg = f"GitHub auth failed for thread {thread_id}: missing source"
|
||||
raise RuntimeError(msg)
|
||||
try:
|
||||
github_token, new_encrypted = await save_encrypted_token_from_email(user_email, source)
|
||||
except ValueError as exc:
|
||||
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
config["metadata"]["github_token_encrypted"] = new_encrypted
|
||||
|
||||
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
|
||||
sandbox_id = await get_sandbox_id_from_metadata(thread_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from typing import Any
|
|||
|
||||
from langgraph.config import get_config
|
||||
|
||||
from ..encryption import decrypt_token
|
||||
from ..utils.github import (
|
||||
create_github_pr,
|
||||
get_github_default_branch,
|
||||
|
|
@ -18,6 +17,7 @@ from ..utils.github import (
|
|||
git_has_unpushed_commits,
|
||||
git_push,
|
||||
)
|
||||
from ..utils.github_token import get_github_token
|
||||
from ..utils.sandbox_state import get_sandbox_backend_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -114,6 +114,7 @@ def commit_and_open_pr(
|
|||
config = get_config()
|
||||
configurable = config.get("configurable", {})
|
||||
thread_id = configurable.get("thread_id")
|
||||
|
||||
if not thread_id:
|
||||
return {"success": False, "error": "Missing thread_id in config", "pr_url": None}
|
||||
|
||||
|
|
@ -168,10 +169,14 @@ def commit_and_open_pr(
|
|||
"pr_url": None,
|
||||
}
|
||||
|
||||
encrypted_token = configurable.get("github_token_encrypted")
|
||||
github_token = decrypt_token(encrypted_token) if encrypted_token else None
|
||||
github_token = get_github_token()
|
||||
if not github_token:
|
||||
return {"success": False, "error": "Missing GitHub token", "pr_url": None}
|
||||
logger.error("commit_and_open_pr missing GitHub token for thread %s", thread_id)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Missing GitHub token",
|
||||
"pr_url": None,
|
||||
}
|
||||
|
||||
push_result = git_push(sandbox_backend, repo_dir, target_branch, github_token)
|
||||
if push_result.exit_code != 0:
|
||||
|
|
|
|||
251
apps/agent/agent/utils/auth.py
Normal file
251
apps/agent/agent/utils/auth.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""GitHub OAuth and LangSmith authentication utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from langgraph.config import get_config
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
from ..encryption import encrypt_token
|
||||
from .linear import comment_on_linear_issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
client = get_client()
|
||||
|
||||
LANGSMITH_API_KEY = os.environ.get("LANGSMITH_API_KEY_PROD", "")
|
||||
LANGSMITH_API_URL = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||
LANGSMITH_HOST_API_URL = os.environ.get("LANGSMITH_HOST_API_URL", "https://api.host.langchain.com")
|
||||
GITHUB_OAUTH_PROVIDER_ID = os.environ.get("GITHUB_OAUTH_PROVIDER_ID", "")
|
||||
X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "")
|
||||
|
||||
logger.debug(
|
||||
"Auth env snapshot: LANGSMITH_API_KEY_PROD=%s LANGSMITH_ENDPOINT=%s "
|
||||
"LANGSMITH_HOST_API_URL=%s GITHUB_OAUTH_PROVIDER_ID=%s X_SERVICE_AUTH_JWT_SECRET=%s",
|
||||
"set" if LANGSMITH_API_KEY else "missing",
|
||||
"set" if LANGSMITH_API_URL else "missing",
|
||||
"set" if LANGSMITH_HOST_API_URL else "missing",
|
||||
"set" if GITHUB_OAUTH_PROVIDER_ID else "missing",
|
||||
"set" if X_SERVICE_AUTH_JWT_SECRET else "missing",
|
||||
)
|
||||
|
||||
|
||||
def get_service_jwt_token_for_user(
|
||||
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
||||
) -> str:
|
||||
"""Create a short-lived service JWT for authenticating as a specific user."""
|
||||
if not X_SERVICE_AUTH_JWT_SECRET:
|
||||
msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys."
|
||||
raise ValueError(msg)
|
||||
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"iat": datetime.now(UTC),
|
||||
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
|
||||
}
|
||||
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]:
|
||||
"""Get the LangSmith user ID and tenant ID from a user's email."""
|
||||
if not LANGSMITH_API_KEY:
|
||||
logger.warning("LangSmith API key not configured; cannot resolve LS user for %s", email)
|
||||
return {"ls_user_id": None, "tenant_id": None}
|
||||
|
||||
url = f"{LANGSMITH_API_URL}/api/v1/workspaces/current/members/active"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"X-API-Key": LANGSMITH_API_KEY},
|
||||
params={"emails": [email]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
members = response.json()
|
||||
|
||||
if members and len(members) > 0:
|
||||
member = members[0]
|
||||
return {
|
||||
"ls_user_id": member.get("ls_user_id"),
|
||||
"tenant_id": member.get("tenant_id"),
|
||||
}
|
||||
except httpx.HTTPError:
|
||||
logger.debug("HTTP error getting LangSmith user info for email")
|
||||
return {"ls_user_id": None, "tenant_id": None}
|
||||
|
||||
|
||||
async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str, Any]:
|
||||
"""Get GitHub OAuth token for a user via LangSmith agent auth."""
|
||||
if not GITHUB_OAUTH_PROVIDER_ID:
|
||||
logger.error("GitHub auth failed: GITHUB_OAUTH_PROVIDER_ID is not configured")
|
||||
return {"error": "GITHUB_OAUTH_PROVIDER_ID not configured"}
|
||||
|
||||
try:
|
||||
service_token = get_service_jwt_token_for_user(ls_user_id, tenant_id)
|
||||
|
||||
headers = {
|
||||
"X-Service-Key": service_token,
|
||||
"X-Tenant-Id": tenant_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"provider": GITHUB_OAUTH_PROVIDER_ID,
|
||||
"scopes": ["repo"],
|
||||
"user_id": ls_user_id,
|
||||
"ls_user_id": ls_user_id,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{LANGSMITH_HOST_API_URL}/v2/auth/authenticate",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
|
||||
token = response_data.get("token")
|
||||
auth_url = response_data.get("url")
|
||||
|
||||
if token:
|
||||
return {"token": token}
|
||||
if auth_url:
|
||||
return {"auth_url": auth_url}
|
||||
return {"error": f"Unexpected auth result: {response_data}"}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
|
||||
return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def resolve_github_token_from_email(email: str) -> dict[str, Any]:
|
||||
"""Resolve a GitHub token for a user identified by email.
|
||||
|
||||
Chains get_ls_user_id_from_email -> get_github_token_for_user.
|
||||
|
||||
Returns:
|
||||
Dict with one of:
|
||||
- {"token": str} on success
|
||||
- {"auth_url": str} if user needs to authenticate via OAuth
|
||||
- {"error": str} on failure; error="no_ls_user" if email not in LangSmith
|
||||
"""
|
||||
user_info = await get_ls_user_id_from_email(email)
|
||||
ls_user_id = user_info.get("ls_user_id")
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
|
||||
if not ls_user_id or not tenant_id:
|
||||
logger.warning(
|
||||
"No LangSmith user found for email %s (ls_user_id=%s, tenant_id=%s)",
|
||||
email,
|
||||
ls_user_id,
|
||||
tenant_id,
|
||||
)
|
||||
return {"error": "no_ls_user", "email": email}
|
||||
|
||||
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
||||
return auth_result
|
||||
|
||||
|
||||
async def leave_failure_comment(
|
||||
source: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Leave an auth failure comment for the appropriate source."""
|
||||
if source == "linear":
|
||||
config = get_config()
|
||||
configurable = config.get("configurable", {})
|
||||
linear_issue = configurable.get("linear_issue", {})
|
||||
issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None
|
||||
if issue_id:
|
||||
logger.info(
|
||||
"Posting auth failure comment to Linear issue %s (source=%s)",
|
||||
issue_id,
|
||||
source,
|
||||
)
|
||||
await comment_on_linear_issue(issue_id, message)
|
||||
return
|
||||
raise ValueError(f"Unknown source: {source}")
|
||||
|
||||
|
||||
async def persist_encrypted_github_token(thread_id: str, token: str) -> str:
|
||||
"""Encrypt a GitHub token and store it on the thread metadata."""
|
||||
encrypted = encrypt_token(token)
|
||||
await client.threads.update(
|
||||
thread_id=thread_id,
|
||||
metadata={"github_token_encrypted": encrypted},
|
||||
)
|
||||
return encrypted
|
||||
|
||||
|
||||
async def save_encrypted_token_from_email(
|
||||
email: str | None,
|
||||
source: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve, encrypt, and store a GitHub token based on user email."""
|
||||
config = get_config()
|
||||
configurable = config.get("configurable", {})
|
||||
thread_id = configurable.get("thread_id")
|
||||
if not thread_id:
|
||||
raise ValueError("GitHub auth failed: missing thread_id")
|
||||
if not email:
|
||||
message = (
|
||||
"❌ **GitHub Auth Error**\n\n"
|
||||
"Failed to authenticate with GitHub: missing_user_email\n\n"
|
||||
"Please try again or contact support."
|
||||
)
|
||||
await leave_failure_comment(source, message)
|
||||
raise ValueError("GitHub auth failed: missing user_email")
|
||||
|
||||
user_info = await get_ls_user_id_from_email(email)
|
||||
ls_user_id = user_info.get("ls_user_id")
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
if not ls_user_id or not tenant_id:
|
||||
message = (
|
||||
"🔐 **GitHub Authentication Required**\n\n"
|
||||
f"Could not find a LangSmith account for **{email}**.\n\n"
|
||||
"Please ensure this email is invited to the main LangSmith organization. "
|
||||
"If your Linear account uses a different email than your LangSmith account, "
|
||||
"you may need to update one of them to match.\n\n"
|
||||
"Once your email is added to LangSmith, "
|
||||
"reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
await leave_failure_comment(source, message)
|
||||
raise ValueError(f"No ls_user_id found from email {email}")
|
||||
|
||||
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
||||
auth_url = auth_result.get("auth_url")
|
||||
if auth_url:
|
||||
message = (
|
||||
"🔐 **GitHub Authentication Required**\n\n"
|
||||
"To allow the Open SWE agent to work on this issue, "
|
||||
"please authenticate with GitHub by clicking the link below:\n\n"
|
||||
f"[Authenticate with GitHub]({auth_url})\n\n"
|
||||
"Once authenticated, reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
await leave_failure_comment(source, message)
|
||||
raise ValueError("User not authenticated.")
|
||||
|
||||
token = auth_result.get("token")
|
||||
if not token:
|
||||
error = auth_result.get("error", "unknown")
|
||||
message = (
|
||||
"❌ **GitHub Auth Error**\n\n"
|
||||
f"Failed to authenticate with GitHub: {error}\n\n"
|
||||
"Please try again or contact support."
|
||||
)
|
||||
await leave_failure_comment(source, message)
|
||||
raise ValueError(f"No token found: {error}")
|
||||
|
||||
encrypted = await persist_encrypted_github_token(thread_id, token)
|
||||
return token, encrypted
|
||||
36
apps/agent/agent/utils/github_token.py
Normal file
36
apps/agent/agent/utils/github_token.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""GitHub token lookup utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
from ..encryption import decrypt_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
client = get_client()
|
||||
|
||||
|
||||
def get_github_token() -> str | None:
|
||||
"""Resolve a GitHub token from config metadata or thread metadata."""
|
||||
config = get_config()
|
||||
configurable = config.get("configurable", {})
|
||||
thread_id = configurable.get("thread_id")
|
||||
|
||||
encrypted_token = None
|
||||
metadata = config.get("metadata", {})
|
||||
if isinstance(metadata, dict):
|
||||
encrypted_token = metadata.get("github_token_encrypted")
|
||||
if not encrypted_token and thread_id:
|
||||
try:
|
||||
thread = asyncio.run(client.threads.get(thread_id))
|
||||
thread_metadata = (thread or {}).get("metadata", {})
|
||||
if isinstance(thread_metadata, dict):
|
||||
encrypted_token = thread_metadata.get("github_token_encrypted")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
||||
return decrypt_token(encrypted_token) if encrypted_token else None
|
||||
|
|
@ -5,19 +5,14 @@ import hmac
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
||||
from langchain_core.messages.content import create_text_block
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# Local import for encryption
|
||||
from .encryption import encrypt_token
|
||||
from .utils.comments import get_recent_comments
|
||||
from .utils.linear import comment_on_linear_issue
|
||||
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,48 +25,8 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||
)
|
||||
|
||||
LANGSMITH_API_KEY = os.environ.get("LANGSMITH_API_KEY_PROD", "")
|
||||
LANGSMITH_API_URL = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||
|
||||
GITHUB_OAUTH_PROVIDER_ID = os.environ.get("GITHUB_OAUTH_PROVIDER_ID", "")
|
||||
|
||||
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
|
||||
|
||||
X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "")
|
||||
|
||||
|
||||
def get_service_jwt_token_for_user(
|
||||
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
||||
) -> str:
|
||||
"""Create a short-lived service JWT for authenticating as a specific user.
|
||||
|
||||
Args:
|
||||
tenant_id: The LangSmith tenant ID to associate with the token
|
||||
user_id: The LangSmith user ID to associate with the token
|
||||
expiration_seconds: Token expiration time in seconds (default: 5 minutes)
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
|
||||
Raises:
|
||||
ValueError: If X_SERVICE_AUTH_JWT_SECRET is not configured
|
||||
"""
|
||||
if not X_SERVICE_AUTH_JWT_SECRET:
|
||||
msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys."
|
||||
raise ValueError(msg)
|
||||
|
||||
exp_datetime = datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
|
||||
exp = int(exp_datetime.timestamp())
|
||||
|
||||
payload = {
|
||||
"sub": "unspecified",
|
||||
"exp": exp,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
LINEAR_TEAM_TO_REPO: dict[str, dict[str, Any] | dict[str, str]] = {
|
||||
"Brace's test workspace": {"owner": "langchain-ai", "name": "open-swe"},
|
||||
|
|
@ -136,100 +91,6 @@ def get_repo_config_from_team_mapping(
|
|||
return {"owner": "langchain-ai", "name": "langchainplus"}
|
||||
|
||||
|
||||
async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]:
|
||||
"""Get the LangSmith user ID and tenant ID from a user's email.
|
||||
|
||||
Args:
|
||||
email: The user's email address
|
||||
|
||||
Returns:
|
||||
Dict with 'ls_user_id' and 'tenant_id' keys (values may be None if not found)
|
||||
"""
|
||||
if not LANGSMITH_API_KEY:
|
||||
return {"ls_user_id": None, "tenant_id": None}
|
||||
|
||||
url = f"{LANGSMITH_API_URL}/api/v1/workspaces/current/members/active"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"X-API-Key": LANGSMITH_API_KEY},
|
||||
params={"emails": [email]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
members = response.json()
|
||||
|
||||
if members and len(members) > 0:
|
||||
member = members[0]
|
||||
return {
|
||||
"ls_user_id": member.get("ls_user_id"),
|
||||
"tenant_id": member.get("tenant_id"),
|
||||
}
|
||||
except httpx.HTTPError:
|
||||
logger.debug("HTTP error getting LangSmith user info for email")
|
||||
return {"ls_user_id": None, "tenant_id": None}
|
||||
|
||||
|
||||
LANGSMITH_HOST_API_URL = os.environ.get("LANGSMITH_HOST_API_URL", "https://api.host.langchain.com")
|
||||
|
||||
|
||||
async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str, Any]:
|
||||
"""Get GitHub OAuth token for a user via LangSmith agent auth.
|
||||
|
||||
Args:
|
||||
ls_user_id: The LangSmith user ID
|
||||
tenant_id: The LangSmith tenant ID
|
||||
|
||||
Returns:
|
||||
Dict with either 'token' key or 'auth_url' key
|
||||
"""
|
||||
if not GITHUB_OAUTH_PROVIDER_ID:
|
||||
logger.error("GitHub auth failed: GITHUB_OAUTH_PROVIDER_ID is not configured")
|
||||
return {"error": "GITHUB_OAUTH_PROVIDER_ID not configured"}
|
||||
|
||||
try:
|
||||
service_token = get_service_jwt_token_for_user(ls_user_id, tenant_id)
|
||||
|
||||
headers = {
|
||||
"X-Service-Key": service_token,
|
||||
"X-Tenant-Id": tenant_id,
|
||||
"X-User-Id": ls_user_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"provider": GITHUB_OAUTH_PROVIDER_ID,
|
||||
"scopes": ["repo"],
|
||||
"user_id": ls_user_id,
|
||||
"ls_user_id": ls_user_id,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{LANGSMITH_HOST_API_URL}/v2/auth/authenticate",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
|
||||
token = response_data.get("token")
|
||||
auth_url = response_data.get("url")
|
||||
|
||||
if token:
|
||||
return {"token": token}
|
||||
if auth_url:
|
||||
return {"auth_url": auth_url}
|
||||
return {"error": f"Unexpected auth result: {response_data}"}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
|
||||
return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def react_to_linear_comment(comment_id: str, emoji: str = "👀") -> bool:
|
||||
"""Add an emoji reaction to a Linear comment.
|
||||
|
||||
|
|
@ -479,59 +340,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
|||
user_email = assignee.get("email")
|
||||
user_name = user_name or assignee.get("name")
|
||||
|
||||
user_mention = f"@{user_name}" if user_name else ""
|
||||
|
||||
logger.info(
|
||||
"User email: %s, GITHUB_OAUTH_PROVIDER_ID set: %s",
|
||||
user_email,
|
||||
bool(GITHUB_OAUTH_PROVIDER_ID),
|
||||
)
|
||||
|
||||
github_token = None
|
||||
if user_email and GITHUB_OAUTH_PROVIDER_ID:
|
||||
user_info = await get_ls_user_id_from_email(user_email)
|
||||
ls_user_id = user_info.get("ls_user_id")
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
logger.info(
|
||||
"LangSmith user ID for %s: %s, tenant_id: %s", user_email, ls_user_id, tenant_id
|
||||
)
|
||||
|
||||
if ls_user_id and tenant_id:
|
||||
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
||||
logger.info("Auth result keys: %s", list(auth_result.keys()))
|
||||
|
||||
if "token" in auth_result:
|
||||
github_token = auth_result["token"]
|
||||
logger.info("GitHub token obtained for user %s", user_email)
|
||||
elif "auth_url" in auth_result:
|
||||
auth_url = auth_result["auth_url"]
|
||||
logger.info("GitHub auth required for user %s, sending auth URL", user_email)
|
||||
comment = (
|
||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
||||
"To allow the Open SWE agent to work on this issue, "
|
||||
"please authenticate with GitHub by clicking the link below:\n\n"
|
||||
f"[Authenticate with GitHub]({auth_url})\n\n"
|
||||
"Once authenticated, reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
|
||||
await comment_on_linear_issue(issue_id, comment)
|
||||
return
|
||||
else:
|
||||
logger.warning("Auth result has neither token nor auth_url: %s", auth_result)
|
||||
else:
|
||||
logger.warning("User %s not found in LangSmith workspace", user_email)
|
||||
comment = (
|
||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
||||
f"Could not find a LangSmith account for **{user_email}**.\n\n"
|
||||
"Please ensure this email is invited to the main LangSmith organization. "
|
||||
"If your Linear account uses a different email than your LangSmith account, "
|
||||
"you may need to update one of them to match.\n\n"
|
||||
"Once your email is added to LangSmith, "
|
||||
"reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
|
||||
await comment_on_linear_issue(issue_id, comment)
|
||||
return
|
||||
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"
|
||||
|
|
@ -667,67 +476,41 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
|||
"linear_issue_number": linear_issue_number,
|
||||
"triggering_user_name": user_name or "",
|
||||
},
|
||||
"user_email": user_email,
|
||||
"source": "linear",
|
||||
}
|
||||
if github_token:
|
||||
configurable["github_token_encrypted"] = encrypt_token(github_token)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
if thread_active:
|
||||
logger.info(
|
||||
"Thread %s is active (busy), will queue message instead of creating run",
|
||||
thread_id,
|
||||
)
|
||||
if thread_active:
|
||||
logger.info(
|
||||
"Thread %s is active (busy), will queue message instead of creating run",
|
||||
thread_id,
|
||||
)
|
||||
|
||||
queued_payload = {"text": prompt, "image_urls": image_urls}
|
||||
queued = await queue_message_for_thread(
|
||||
thread_id=thread_id,
|
||||
message_content=queued_payload,
|
||||
)
|
||||
queued_payload = {"text": prompt, "image_urls": image_urls}
|
||||
queued = await queue_message_for_thread(
|
||||
thread_id=thread_id,
|
||||
message_content=queued_payload,
|
||||
)
|
||||
|
||||
if queued:
|
||||
logger.info(
|
||||
"Message queued for thread %s, will be processed by middleware", thread_id
|
||||
)
|
||||
else:
|
||||
logger.error("Failed to queue message for thread %s", thread_id)
|
||||
if queued:
|
||||
logger.info("Message queued for thread %s, will be processed by middleware", thread_id)
|
||||
else:
|
||||
logger.info("Creating LangGraph run for thread %s", thread_id)
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
await langgraph_client.runs.create(
|
||||
thread_id,
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": content_blocks}]},
|
||||
config={"configurable": configurable},
|
||||
if_not_exists="create",
|
||||
)
|
||||
logger.info("LangGraph run created successfully for thread %s", thread_id)
|
||||
logger.error("Failed to queue message for thread %s", thread_id)
|
||||
else:
|
||||
logger.warning("No GitHub token available, cannot create run for issue %s", issue_id)
|
||||
if not user_email:
|
||||
comment = (
|
||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
||||
"Could not determine the user email from this issue. "
|
||||
"Please ensure your Linear account has an email address configured.\n\n"
|
||||
"Reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
elif not GITHUB_OAUTH_PROVIDER_ID:
|
||||
comment = (
|
||||
f"❌ **Configuration Error** {user_mention}\n\n"
|
||||
"The Open SWE agent is not properly configured (missing GitHub OAuth provider).\n\n"
|
||||
"Please contact your administrator."
|
||||
)
|
||||
else:
|
||||
comment = (
|
||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
||||
"Unable to authenticate with GitHub. "
|
||||
"Please ensure you have connected your GitHub account in LangSmith.\n\n"
|
||||
"Reply to this issue mentioning @openswe to retry."
|
||||
)
|
||||
|
||||
await comment_on_linear_issue(issue_id, comment)
|
||||
logger.info("Creating LangGraph run for thread %s", thread_id)
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
await langgraph_client.runs.create(
|
||||
thread_id,
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": content_blocks}]},
|
||||
config={"configurable": configurable},
|
||||
if_not_exists="create",
|
||||
)
|
||||
logger.info("LangGraph run created successfully for thread %s", thread_id)
|
||||
|
||||
|
||||
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue