mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
feat: TTL and revocation handling for cached GitHub OAuth tokens [closes AB-2322] (#1280)
* feat: TTL and revocation handling for cached GitHub OAuth tokens [closes AB-2322] Persist github_token_expires_at alongside github_token_encrypted, treat expired cache entries as missing so we re-resolve before kicking off runs, and invalidate the cached ciphertext on a downstream 401 so the next invocation gets a fresh token instead of replaying a revoked one. Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> * webapp: forward installation-token expiry to reviewer cache writes The three reviewer-thread persist sites in webapp.py were calling get_github_app_installation_token() (no expiry) and persist_encrypted_github_token without expires_at, so cached App tokens were treated as never-expiring even though they actually expire in ~1 hour. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
parent
6a984d86c4
commit
85343fab63
14 changed files with 692 additions and 81 deletions
|
|
@ -271,13 +271,17 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||||
|
|
||||||
if config["configurable"].get("source"):
|
if config["configurable"].get("source"):
|
||||||
cached_token, cached_encrypted = await get_github_token_from_thread(thread_id)
|
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
|
||||||
|
thread_id
|
||||||
|
)
|
||||||
if cached_token and cached_encrypted:
|
if cached_token and cached_encrypted:
|
||||||
config["metadata"]["github_token_encrypted"] = cached_encrypted
|
config["metadata"]["github_token_encrypted"] = cached_encrypted
|
||||||
|
config["metadata"]["github_token_expires_at"] = cached_expires_at
|
||||||
del cached_token
|
del cached_token
|
||||||
else:
|
else:
|
||||||
_token, new_encrypted = await resolve_github_token(config, thread_id)
|
_token, new_encrypted, new_expires_at = await resolve_github_token(config, thread_id)
|
||||||
config["metadata"]["github_token_encrypted"] = new_encrypted
|
config["metadata"]["github_token_encrypted"] = new_encrypted
|
||||||
|
config["metadata"]["github_token_expires_at"] = new_expires_at
|
||||||
del _token
|
del _token
|
||||||
|
|
||||||
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
|
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .reviewer_findings import Finding
|
from .reviewer_findings import Finding
|
||||||
|
from .utils.github_token import GitHubAuthError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -121,7 +122,13 @@ async def post_pull_request_review(
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, headers=headers, json=payload, timeout=30)
|
response = await client.post(url, headers=headers, json=payload, timeout=30)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise GitHubAuthError(
|
||||||
|
f"GitHub returned 401 posting PR review for {owner}/{repo}#{pr_number}"
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
except GitHubAuthError:
|
||||||
|
raise
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
body = (e.response.text or "")[:500]
|
body = (e.response.text or "")[:500]
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,9 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
tools=[],
|
tools=[],
|
||||||
).with_config(config)
|
).with_config(config)
|
||||||
|
|
||||||
github_token, new_encrypted = await resolve_github_token(config, thread_id)
|
github_token, new_encrypted, new_expires_at = await resolve_github_token(config, thread_id)
|
||||||
config["metadata"]["github_token_encrypted"] = new_encrypted
|
config["metadata"]["github_token_encrypted"] = new_encrypted
|
||||||
|
config["metadata"]["github_token_expires_at"] = new_expires_at
|
||||||
triggering_user_identity = await asyncio.to_thread(
|
triggering_user_identity = await asyncio.to_thread(
|
||||||
resolve_triggering_user_identity, config, github_token
|
resolve_triggering_user_identity, config, github_token
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ from ..reviewer_publish import (
|
||||||
render_review_body,
|
render_review_body,
|
||||||
resolve_review_thread,
|
resolve_review_thread,
|
||||||
)
|
)
|
||||||
from ..utils.github_token import get_github_token
|
from ..utils.github_token import (
|
||||||
|
GitHubAuthError,
|
||||||
|
get_github_token,
|
||||||
|
invalidate_cached_github_token,
|
||||||
|
)
|
||||||
from ..utils.slack import post_slack_thread_reply
|
from ..utils.slack import post_slack_thread_reply
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -90,18 +94,31 @@ def publish_review(
|
||||||
if not token:
|
if not token:
|
||||||
return {"success": False, "error": "No GitHub token available"}
|
return {"success": False, "error": "No GitHub token available"}
|
||||||
|
|
||||||
return asyncio.run(
|
try:
|
||||||
_publish_review_async(
|
return asyncio.run(
|
||||||
owner=str(repo_config["owner"]),
|
_publish_review_async(
|
||||||
repo=str(repo_config["name"]),
|
owner=str(repo_config["owner"]),
|
||||||
pr_number=pr_number,
|
repo=str(repo_config["name"]),
|
||||||
head_sha=head_sha,
|
pr_number=pr_number,
|
||||||
token=token,
|
head_sha=head_sha,
|
||||||
severity_threshold=_cast_severity(severity_threshold),
|
token=token,
|
||||||
cap=cap,
|
severity_threshold=_cast_severity(severity_threshold),
|
||||||
is_re_review=is_re_review,
|
cap=cap,
|
||||||
|
is_re_review=is_re_review,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
except GitHubAuthError as exc:
|
||||||
|
thread_id = get_thread_id_from_runtime()
|
||||||
|
if thread_id:
|
||||||
|
asyncio.run(invalidate_cached_github_token(thread_id))
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"GitHub returned 401 — the cached OAuth token is invalid or revoked. "
|
||||||
|
"Please re-authenticate and trigger the review again."
|
||||||
|
),
|
||||||
|
"auth_error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _cast_severity(value: str) -> Severity:
|
def _cast_severity(value: str) -> Severity:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from langgraph.graph.state import RunnableConfig
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
from ..encryption import encrypt_token
|
from ..encryption import encrypt_token
|
||||||
from .github_app import get_github_app_installation_token
|
from .github_app import get_github_app_installation_token_with_expiry
|
||||||
from .github_token import get_github_token_from_thread
|
from .github_token import get_github_token_from_thread
|
||||||
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
||||||
from .linear import comment_on_linear_issue
|
from .linear import comment_on_linear_issue
|
||||||
|
|
@ -122,6 +122,19 @@ async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]:
|
||||||
return {"ls_user_id": None, "tenant_id": None}
|
return {"ls_user_id": None, "tenant_id": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_expires_at(response_data: dict[str, Any]) -> str | None:
|
||||||
|
"""Pull an expiry from a LangSmith auth response in any of its known shapes."""
|
||||||
|
expires_at = response_data.get("expires_at") or response_data.get("expiresAt")
|
||||||
|
if isinstance(expires_at, str) and expires_at:
|
||||||
|
return expires_at
|
||||||
|
if isinstance(expires_at, int | float):
|
||||||
|
return datetime.fromtimestamp(float(expires_at), tz=UTC).isoformat()
|
||||||
|
expires_in = response_data.get("expires_in") or response_data.get("expiresIn")
|
||||||
|
if isinstance(expires_in, int | float) and expires_in > 0:
|
||||||
|
return (datetime.now(UTC) + timedelta(seconds=int(expires_in))).isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str, Any]:
|
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."""
|
"""Get GitHub OAuth token for a user via LangSmith agent auth."""
|
||||||
if not GITHUB_OAUTH_PROVIDER_ID:
|
if not GITHUB_OAUTH_PROVIDER_ID:
|
||||||
|
|
@ -159,7 +172,11 @@ async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str
|
||||||
auth_url = response_data.get("url")
|
auth_url = response_data.get("url")
|
||||||
|
|
||||||
if token:
|
if token:
|
||||||
return {"token": token}
|
result: dict[str, Any] = {"token": token}
|
||||||
|
expires_at = _extract_expires_at(response_data)
|
||||||
|
if expires_at:
|
||||||
|
result["expires_at"] = expires_at
|
||||||
|
return result
|
||||||
if auth_url:
|
if auth_url:
|
||||||
return {"auth_url": auth_url}
|
return {"auth_url": auth_url}
|
||||||
return {"error": f"Unexpected auth result: {response_data}"}
|
return {"error": f"Unexpected auth result: {response_data}"}
|
||||||
|
|
@ -265,20 +282,23 @@ async def leave_failure_comment(
|
||||||
raise ValueError(f"Unknown source: {source}")
|
raise ValueError(f"Unknown source: {source}")
|
||||||
|
|
||||||
|
|
||||||
async def persist_encrypted_github_token(thread_id: str, token: str) -> str:
|
async def persist_encrypted_github_token(
|
||||||
"""Encrypt a GitHub token and store it on the thread metadata."""
|
thread_id: str, token: str, expires_at: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Encrypt a GitHub token and store it (and its expiry) on the thread metadata."""
|
||||||
encrypted = encrypt_token(token)
|
encrypted = encrypt_token(token)
|
||||||
await client.threads.update(
|
metadata: dict[str, Any] = {
|
||||||
thread_id=thread_id,
|
"github_token_encrypted": encrypted,
|
||||||
metadata={"github_token_encrypted": encrypted},
|
"github_token_expires_at": expires_at,
|
||||||
)
|
}
|
||||||
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||||
return encrypted
|
return encrypted
|
||||||
|
|
||||||
|
|
||||||
async def save_encrypted_token_from_email(
|
async def save_encrypted_token_from_email(
|
||||||
email: str | None,
|
email: str | None,
|
||||||
source: str,
|
source: str,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str, str | None]:
|
||||||
"""Resolve, encrypt, and store a GitHub token based on user email."""
|
"""Resolve, encrypt, and store a GitHub token based on user email."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
configurable = config.get("configurable", {})
|
configurable = config.get("configurable", {})
|
||||||
|
|
@ -337,13 +357,14 @@ async def save_encrypted_token_from_email(
|
||||||
await leave_failure_comment(source, message)
|
await leave_failure_comment(source, message)
|
||||||
raise ValueError(f"No token found: {error}")
|
raise ValueError(f"No token found: {error}")
|
||||||
|
|
||||||
encrypted = await persist_encrypted_github_token(thread_id, token)
|
expires_at = auth_result.get("expires_at") if isinstance(auth_result, dict) else None
|
||||||
return token, encrypted
|
encrypted = await persist_encrypted_github_token(thread_id, token, expires_at=expires_at)
|
||||||
|
return token, encrypted, expires_at
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str]:
|
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str, str | None]:
|
||||||
"""Get a GitHub App installation token and persist it for the thread."""
|
"""Get a GitHub App installation token and persist it for the thread."""
|
||||||
bot_token = await get_github_app_installation_token()
|
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
|
||||||
if not bot_token:
|
if not bot_token:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Bot-token-only mode is active (LANGSMITH_API_KEY_PROD set without "
|
"Bot-token-only mode is active (LANGSMITH_API_KEY_PROD set without "
|
||||||
|
|
@ -353,11 +374,13 @@ async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str]:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id
|
"Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id
|
||||||
)
|
)
|
||||||
encrypted = await persist_encrypted_github_token(thread_id, bot_token)
|
encrypted = await persist_encrypted_github_token(thread_id, bot_token, expires_at=expires_at)
|
||||||
return bot_token, encrypted
|
return bot_token, encrypted, expires_at
|
||||||
|
|
||||||
|
|
||||||
async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str]:
|
async def resolve_github_token(
|
||||||
|
config: RunnableConfig, thread_id: str
|
||||||
|
) -> tuple[str, str, str | None]:
|
||||||
"""Resolve a GitHub token from the run config based on the source.
|
"""Resolve a GitHub token from the run config based on the source.
|
||||||
|
|
||||||
Routes to the correct auth method depending on whether the run was
|
Routes to the correct auth method depending on whether the run was
|
||||||
|
|
@ -368,7 +391,8 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
|
||||||
for all operations instead of per-user OAuth tokens.
|
for all operations instead of per-user OAuth tokens.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(github_token, new_encrypted) tuple.
|
(github_token, new_encrypted, expires_at) tuple. ``expires_at`` is the
|
||||||
|
ISO-8601 expiry persisted alongside the ciphertext, or ``None``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If source is missing or token resolution fails.
|
RuntimeError: If source is missing or token resolution fails.
|
||||||
|
|
@ -384,9 +408,11 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if source == "github":
|
if source == "github":
|
||||||
cached_token, cached_encrypted = await get_github_token_from_thread(thread_id)
|
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
|
||||||
|
thread_id
|
||||||
|
)
|
||||||
if cached_token and cached_encrypted:
|
if cached_token and cached_encrypted:
|
||||||
return cached_token, cached_encrypted
|
return cached_token, cached_encrypted, cached_expires_at
|
||||||
github_login = configurable.get("github_login")
|
github_login = configurable.get("github_login")
|
||||||
email = GITHUB_USER_EMAIL_MAP.get(github_login or "")
|
email = GITHUB_USER_EMAIL_MAP.get(github_login or "")
|
||||||
if not email:
|
if not email:
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,19 @@ async def get_github_app_installation_token() -> str | None:
|
||||||
Returns:
|
Returns:
|
||||||
Installation access token string, or None if unavailable.
|
Installation access token string, or None if unavailable.
|
||||||
"""
|
"""
|
||||||
|
token, _ = await get_github_app_installation_token_with_expiry()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_github_app_installation_token_with_expiry() -> tuple[str | None, str | None]:
|
||||||
|
"""Exchange the GitHub App JWT for an installation access token and its expiry.
|
||||||
|
|
||||||
|
Returns ``(token, expires_at)`` where ``expires_at`` is the ISO-8601 string
|
||||||
|
returned by GitHub (typically 1 hour out). Either value may be ``None``.
|
||||||
|
"""
|
||||||
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY or not GITHUB_APP_INSTALLATION_ID:
|
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY or not GITHUB_APP_INSTALLATION_ID:
|
||||||
logger.debug("GitHub App env vars not fully configured, skipping app token")
|
logger.debug("GitHub App env vars not fully configured, skipping app token")
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_jwt = _generate_app_jwt()
|
app_jwt = _generate_app_jwt()
|
||||||
|
|
@ -50,7 +60,8 @@ async def get_github_app_installation_token() -> str | None:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json().get("token")
|
data = response.json()
|
||||||
|
return data.get("token"), data.get("expires_at")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to get GitHub App installation token")
|
logger.exception("Failed to get GitHub App installation token")
|
||||||
return None
|
return None, None
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,28 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from .github_token import GitHubAuthError
|
||||||
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GitHubAuthError",
|
||||||
|
"OPEN_SWE_TAGS",
|
||||||
|
"build_pr_prompt",
|
||||||
|
"extract_pr_context",
|
||||||
|
"fetch_issue_comments",
|
||||||
|
"fetch_pr_branch",
|
||||||
|
"fetch_pr_comments_since_last_tag",
|
||||||
|
"format_github_comment_body_for_prompt",
|
||||||
|
"get_thread_id_from_branch",
|
||||||
|
"parse_github_review_command",
|
||||||
|
"post_github_comment",
|
||||||
|
"react_to_github_comment",
|
||||||
|
"sanitize_github_comment_body",
|
||||||
|
"verify_github_signature",
|
||||||
|
]
|
||||||
|
|
||||||
OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev")
|
OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev")
|
||||||
_OPEN_SWE_MENTION_RE = re.compile(r"(?i)@(?:openswe-dev|open-swe|openswe)\b")
|
_OPEN_SWE_MENTION_RE = re.compile(r"(?i)@(?:openswe-dev|open-swe|openswe)\b")
|
||||||
_REVIEW_COMMAND_RE = re.compile(r"(?i)\Areview(?:\s+(https?://\S+))?\s*\Z")
|
_REVIEW_COMMAND_RE = re.compile(r"(?i)\Areview(?:\s+(https?://\S+))?\s*\Z")
|
||||||
|
|
@ -134,8 +152,12 @@ async def react_to_github_comment(
|
||||||
},
|
},
|
||||||
json={"content": "eyes"},
|
json={"content": "eyes"},
|
||||||
)
|
)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise GitHubAuthError(f"GitHub returned 401 reacting to comment {comment_id}")
|
||||||
# 200 = already reacted, 201 = just created
|
# 200 = already reacted, 201 = just created
|
||||||
return response.status_code in (200, 201)
|
return response.status_code in (200, 201)
|
||||||
|
except GitHubAuthError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to react to GitHub comment %s", comment_id)
|
logger.exception("Failed to react to GitHub comment %s", comment_id)
|
||||||
return False
|
return False
|
||||||
|
|
@ -161,11 +183,17 @@ async def _react_via_graphql(node_id: str | None, *, token: str) -> bool:
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
json={"query": query, "variables": {"subjectId": node_id}},
|
json={"query": query, "variables": {"subjectId": node_id}},
|
||||||
)
|
)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise GitHubAuthError(
|
||||||
|
f"GitHub returned 401 reacting via GraphQL for node {node_id}"
|
||||||
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if "errors" in data:
|
if "errors" in data:
|
||||||
logger.warning("GraphQL reaction errors: %s", data["errors"])
|
logger.warning("GraphQL reaction errors: %s", data["errors"])
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
except GitHubAuthError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to react via GraphQL for node_id %s", node_id)
|
logger.exception("Failed to react via GraphQL for node_id %s", node_id)
|
||||||
return False
|
return False
|
||||||
|
|
@ -458,6 +486,8 @@ async def _fetch_paginated(
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
response = await client.get(url, headers=headers, params=params)
|
response = await client.get(url, headers=headers, params=params)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise GitHubAuthError(f"GitHub returned 401 fetching {url}")
|
||||||
if response.status_code != 200: # noqa: PLR2004
|
if response.status_code != 200: # noqa: PLR2004
|
||||||
logger.warning("GitHub API returned %s for %s", response.status_code, url)
|
logger.warning("GitHub API returned %s for %s", response.status_code, url)
|
||||||
break
|
break
|
||||||
|
|
@ -468,6 +498,8 @@ async def _fetch_paginated(
|
||||||
if len(page_data) < 100: # noqa: PLR2004
|
if len(page_data) < 100: # noqa: PLR2004
|
||||||
break
|
break
|
||||||
params["page"] += 1
|
params["page"] += 1
|
||||||
|
except GitHubAuthError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch %s", url)
|
logger.exception("Failed to fetch %s", url)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
|
|
@ -15,6 +16,16 @@ from ..encryption import decrypt_token
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_GITHUB_TOKEN_METADATA_KEY = "github_token_encrypted"
|
_GITHUB_TOKEN_METADATA_KEY = "github_token_encrypted"
|
||||||
|
_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY = "github_token_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubAuthError(Exception):
|
||||||
|
"""Raised when a GitHub call returns 401, signalling a stale/revoked token."""
|
||||||
|
|
||||||
|
|
||||||
|
# Treat tokens with <= this many seconds remaining as expired so we re-auth
|
||||||
|
# before kicking off long agent runs.
|
||||||
|
_GITHUB_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
||||||
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
||||||
|
|
@ -31,34 +42,112 @@ def _decrypt_github_token(encrypted_token: str | None) -> str | None:
|
||||||
return decrypt_token(encrypted_token)
|
return decrypt_token(encrypted_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(expires_at: Any, *, now: datetime | None = None) -> bool:
|
||||||
|
"""Return True when ``expires_at`` is past (or close to) ``now``.
|
||||||
|
|
||||||
|
Accepts ISO-8601 strings (with or without trailing Z) and unix timestamps.
|
||||||
|
Unparseable values are treated as not expired so we don't break callers
|
||||||
|
that haven't started persisting an expiry yet.
|
||||||
|
"""
|
||||||
|
if expires_at is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parsed: datetime | None = None
|
||||||
|
if isinstance(expires_at, int | float):
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromtimestamp(float(expires_at), tz=UTC)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
return False
|
||||||
|
elif isinstance(expires_at, str):
|
||||||
|
raw = expires_at.strip()
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
if raw.endswith("Z"):
|
||||||
|
raw = raw[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
current = (now or datetime.now(UTC)).astimezone(UTC)
|
||||||
|
return (parsed - current).total_seconds() <= _GITHUB_TOKEN_EXPIRY_SKEW_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def _read_token_if_fresh(metadata: dict[str, Any]) -> str | None:
|
||||||
|
"""Decrypt the cached token only if it has not expired."""
|
||||||
|
encrypted = _read_encrypted_github_token(metadata)
|
||||||
|
if not encrypted:
|
||||||
|
return None
|
||||||
|
if _is_expired(metadata.get(_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY)):
|
||||||
|
return None
|
||||||
|
return _decrypt_github_token(encrypted)
|
||||||
|
|
||||||
|
|
||||||
def get_github_token(run_config: Mapping[str, Any] | None = None) -> str | None:
|
def get_github_token(run_config: Mapping[str, Any] | None = None) -> str | None:
|
||||||
"""Resolve a GitHub token from run metadata.
|
"""Resolve a GitHub token from run metadata.
|
||||||
|
|
||||||
Pass ``run_config`` when LangGraph runnable config is already available (e.g. after
|
Pass ``run_config`` when LangGraph runnable config is already available (e.g. after
|
||||||
``get_config()`` in callers). Omit to read from ``get_config()`` (required runnable
|
``get_config()`` in callers). Omit to read from ``get_config()`` (required runnable
|
||||||
context).
|
context). Returns ``None`` for tokens whose ``github_token_expires_at`` is past.
|
||||||
"""
|
"""
|
||||||
resolved = run_config if run_config is not None else get_config()
|
resolved = run_config if run_config is not None else get_config()
|
||||||
return _decrypt_github_token(_read_encrypted_github_token(resolved.get("metadata", {})))
|
return _read_token_if_fresh(resolved.get("metadata", {}))
|
||||||
|
|
||||||
|
|
||||||
async def get_github_token_from_thread(thread_id: str) -> tuple[str | None, str | None]:
|
async def get_github_token_from_thread(
|
||||||
|
thread_id: str,
|
||||||
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
"""Resolve a GitHub token from LangGraph thread metadata.
|
"""Resolve a GitHub token from LangGraph thread metadata.
|
||||||
|
|
||||||
Returns:
|
Returns ``(None, None, None)`` when no token is cached or when the cached
|
||||||
A `(token, encrypted_token)` tuple. Either value may be `None`.
|
token's ``github_token_expires_at`` has elapsed — callers must treat the
|
||||||
|
cache as missing in that case and re-resolve. On a fresh hit, returns the
|
||||||
|
decrypted token, its ciphertext, and the persisted expiry (or ``None``).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
thread = await client.threads.get(thread_id)
|
thread = await client.threads.get(thread_id)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
logger.debug("Thread %s not found while looking up GitHub token", thread_id)
|
logger.debug("Thread %s not found while looking up GitHub token", thread_id)
|
||||||
return None, None
|
return None, None, None
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
||||||
return None, None
|
return None, None, None
|
||||||
|
|
||||||
|
metadata = (thread or {}).get("metadata", {})
|
||||||
|
encrypted_token = _read_encrypted_github_token(metadata)
|
||||||
|
if not encrypted_token:
|
||||||
|
return None, None, None
|
||||||
|
expires_at_raw = metadata.get(_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY)
|
||||||
|
if _is_expired(expires_at_raw):
|
||||||
|
logger.info("Cached GitHub token for thread %s has expired; re-resolving", thread_id)
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
encrypted_token = _read_encrypted_github_token((thread or {}).get("metadata", {}))
|
|
||||||
token = _decrypt_github_token(encrypted_token)
|
token = _decrypt_github_token(encrypted_token)
|
||||||
if token:
|
if token:
|
||||||
logger.info("Found GitHub token in thread metadata for thread %s", thread_id)
|
logger.info("Found GitHub token in thread metadata for thread %s", thread_id)
|
||||||
return token, encrypted_token
|
expires_at = expires_at_raw if isinstance(expires_at_raw, str) else None
|
||||||
|
return token, encrypted_token, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_cached_github_token(thread_id: str) -> None:
|
||||||
|
"""Clear a cached GitHub token from thread metadata.
|
||||||
|
|
||||||
|
Called when a downstream GitHub API call returns 401, so the next run
|
||||||
|
re-resolves a fresh token instead of replaying the revoked ciphertext.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await client.threads.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
metadata={
|
||||||
|
_GITHUB_TOKEN_METADATA_KEY: None,
|
||||||
|
_GITHUB_TOKEN_EXPIRES_AT_METADATA_KEY: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("Invalidated cached GitHub token for thread %s", thread_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to invalidate cached GitHub token for thread %s", thread_id)
|
||||||
|
|
|
||||||
127
agent/webapp.py
127
agent/webapp.py
|
|
@ -29,9 +29,13 @@ from .utils.auth import (
|
||||||
)
|
)
|
||||||
from .utils.authorship import OPEN_SWE_BOT_NAME
|
from .utils.authorship import OPEN_SWE_BOT_NAME
|
||||||
from .utils.comments import get_recent_comments
|
from .utils.comments import get_recent_comments
|
||||||
from .utils.github_app import get_github_app_installation_token
|
from .utils.github_app import (
|
||||||
|
get_github_app_installation_token,
|
||||||
|
get_github_app_installation_token_with_expiry,
|
||||||
|
)
|
||||||
from .utils.github_comments import (
|
from .utils.github_comments import (
|
||||||
OPEN_SWE_TAGS,
|
OPEN_SWE_TAGS,
|
||||||
|
GitHubAuthError,
|
||||||
build_pr_prompt,
|
build_pr_prompt,
|
||||||
extract_pr_context,
|
extract_pr_context,
|
||||||
fetch_issue_comments,
|
fetch_issue_comments,
|
||||||
|
|
@ -44,7 +48,7 @@ from .utils.github_comments import (
|
||||||
verify_github_signature,
|
verify_github_signature,
|
||||||
)
|
)
|
||||||
from .utils.github_org_membership import INTERNAL_BOT_LOGINS, is_user_active_org_member
|
from .utils.github_org_membership import INTERNAL_BOT_LOGINS, is_user_active_org_member
|
||||||
from .utils.github_token import get_github_token_from_thread
|
from .utils.github_token import get_github_token_from_thread, invalidate_cached_github_token
|
||||||
from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
|
from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
|
||||||
from .utils.linear import post_linear_trace_comment
|
from .utils.linear import post_linear_trace_comment
|
||||||
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
|
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
|
||||||
|
|
@ -1514,7 +1518,7 @@ async def trigger_pr_review_from_ref(
|
||||||
if not _is_repo_allowed_for_reviewer(repo_config):
|
if not _is_repo_allowed_for_reviewer(repo_config):
|
||||||
return {"success": False, "error": "Repository not allowed for reviewer"}
|
return {"success": False, "error": "Repository not allowed for reviewer"}
|
||||||
|
|
||||||
app_token = await get_github_app_installation_token()
|
app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry()
|
||||||
if not app_token:
|
if not app_token:
|
||||||
logger.warning("No GitHub App token available for PR reviewer request")
|
logger.warning("No GitHub App token available for PR reviewer request")
|
||||||
return {"success": False, "error": "No GitHub App token available"}
|
return {"success": False, "error": "No GitHub App token available"}
|
||||||
|
|
@ -1540,7 +1544,7 @@ async def trigger_pr_review_from_ref(
|
||||||
return {"success": False, "error": "Could not create reviewer thread"}
|
return {"success": False, "error": "Could not create reviewer thread"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await persist_encrypted_github_token(thread_id, app_token)
|
await persist_encrypted_github_token(thread_id, app_token, expires_at=app_token_expires_at)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
||||||
return {"success": False, "error": "Could not persist reviewer token"}
|
return {"success": False, "error": "Could not persist reviewer token"}
|
||||||
|
|
@ -1663,7 +1667,7 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None:
|
||||||
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
repo_config.get("owner", ""), repo_config.get("name", ""), pr_number
|
||||||
)
|
)
|
||||||
|
|
||||||
app_token = await get_github_app_installation_token()
|
app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry()
|
||||||
if not app_token:
|
if not app_token:
|
||||||
logger.warning("No GitHub App token available for PR reviewer request")
|
logger.warning("No GitHub App token available for PR reviewer request")
|
||||||
return
|
return
|
||||||
|
|
@ -1673,7 +1677,7 @@ async def process_github_pr_review_request(payload: dict[str, Any]) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await persist_encrypted_github_token(thread_id, app_token)
|
await persist_encrypted_github_token(thread_id, app_token, expires_at=app_token_expires_at)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
||||||
return
|
return
|
||||||
|
|
@ -1896,7 +1900,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
app_token = await get_github_app_installation_token()
|
app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry()
|
||||||
if not app_token:
|
if not app_token:
|
||||||
logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
logger.warning("No GitHub App token for push re-review on %s", head_ref)
|
||||||
return
|
return
|
||||||
|
|
@ -1951,7 +1955,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
if not await _ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
if not await _ensure_thread_exists_for_metadata(thread_id, langgraph_client):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await persist_encrypted_github_token(thread_id, app_token)
|
await persist_encrypted_github_token(thread_id, app_token, expires_at=app_token_expires_at)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
||||||
return
|
return
|
||||||
|
|
@ -2002,24 +2006,35 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_thread_github_token_after_401(thread_id: str, email: str) -> str | None:
|
||||||
|
"""Invalidate the cached token after a 401 and try to resolve a fresh one."""
|
||||||
|
logger.warning(
|
||||||
|
"GitHub returned 401 for thread %s; invalidating cached token and re-resolving",
|
||||||
|
thread_id,
|
||||||
|
)
|
||||||
|
await invalidate_cached_github_token(thread_id)
|
||||||
|
return await _get_or_resolve_thread_github_token(thread_id, email)
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
|
async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
|
||||||
"""Resolve and persist a GitHub token for a thread when available.
|
"""Resolve and persist a GitHub token for a thread when available.
|
||||||
|
|
||||||
|
Skips the cached ciphertext when its ``github_token_expires_at`` is past.
|
||||||
In bot-token-only mode, returns a fresh GitHub App installation token
|
In bot-token-only mode, returns a fresh GitHub App installation token
|
||||||
instead of resolving per-user OAuth tokens.
|
instead of resolving per-user OAuth tokens.
|
||||||
"""
|
"""
|
||||||
if is_bot_token_only_mode():
|
if is_bot_token_only_mode():
|
||||||
bot_token = await get_github_app_installation_token()
|
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
|
||||||
if bot_token:
|
if bot_token:
|
||||||
try:
|
try:
|
||||||
await persist_encrypted_github_token(thread_id, bot_token)
|
await persist_encrypted_github_token(thread_id, bot_token, expires_at=expires_at)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist bot token for thread %s", thread_id)
|
logger.warning("Could not persist bot token for thread %s", thread_id)
|
||||||
return bot_token
|
return bot_token
|
||||||
logger.warning("Bot-token-only mode but GitHub App token unavailable")
|
logger.warning("Bot-token-only mode but GitHub App token unavailable")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
github_token, _encrypted_token = await get_github_token_from_thread(thread_id)
|
github_token, _encrypted_token, _expires_at = await get_github_token_from_thread(thread_id)
|
||||||
if github_token:
|
if github_token:
|
||||||
return github_token
|
return github_token
|
||||||
|
|
||||||
|
|
@ -2029,7 +2044,9 @@ async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await persist_encrypted_github_token(thread_id, github_token)
|
await persist_encrypted_github_token(
|
||||||
|
thread_id, github_token, expires_at=auth_result.get("expires_at")
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist GitHub token for thread %s", thread_id)
|
logger.warning("Could not persist GitHub token for thread %s", thread_id)
|
||||||
return github_token
|
return github_token
|
||||||
|
|
@ -2101,20 +2118,45 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
|
||||||
return
|
return
|
||||||
|
|
||||||
if comment_id:
|
if comment_id:
|
||||||
await react_to_github_comment(
|
try:
|
||||||
repo_config,
|
await react_to_github_comment(
|
||||||
comment_id,
|
repo_config,
|
||||||
event_type=event_type,
|
comment_id,
|
||||||
token=github_token,
|
event_type=event_type,
|
||||||
pull_number=pr_number,
|
token=github_token,
|
||||||
node_id=node_id,
|
pull_number=pr_number,
|
||||||
)
|
node_id=node_id,
|
||||||
|
)
|
||||||
|
except GitHubAuthError:
|
||||||
|
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
|
||||||
|
if not github_token:
|
||||||
|
logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
||||||
|
return
|
||||||
|
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:
|
if not pr_number:
|
||||||
logger.warning("No PR number found in payload, skipping")
|
logger.warning("No PR number found in payload, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
comments = await fetch_pr_comments_since_last_tag(repo_config, pr_number, token=github_token)
|
try:
|
||||||
|
comments = await fetch_pr_comments_since_last_tag(
|
||||||
|
repo_config, pr_number, token=github_token
|
||||||
|
)
|
||||||
|
except GitHubAuthError:
|
||||||
|
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
|
||||||
|
if not github_token:
|
||||||
|
logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
|
||||||
|
return
|
||||||
|
comments = await fetch_pr_comments_since_last_tag(
|
||||||
|
repo_config, pr_number, token=github_token
|
||||||
|
)
|
||||||
if not comments:
|
if not comments:
|
||||||
logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
|
logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
|
||||||
return
|
return
|
||||||
|
|
@ -2176,12 +2218,31 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
if not reaction_token:
|
if not reaction_token:
|
||||||
logger.warning("No GitHub token available to react to issue comment %s", comment_id)
|
logger.warning("No GitHub token available to react to issue comment %s", comment_id)
|
||||||
else:
|
else:
|
||||||
reacted = await react_to_github_comment(
|
try:
|
||||||
repo_config,
|
reacted = await react_to_github_comment(
|
||||||
comment_id,
|
repo_config,
|
||||||
event_type="issue_comment",
|
comment_id,
|
||||||
token=reaction_token,
|
event_type="issue_comment",
|
||||||
)
|
token=reaction_token,
|
||||||
|
)
|
||||||
|
except GitHubAuthError:
|
||||||
|
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
|
||||||
|
reaction_token = github_token or app_token
|
||||||
|
reacted = False
|
||||||
|
if reaction_token:
|
||||||
|
try:
|
||||||
|
reacted = await react_to_github_comment(
|
||||||
|
repo_config,
|
||||||
|
comment_id,
|
||||||
|
event_type="issue_comment",
|
||||||
|
token=reaction_token,
|
||||||
|
)
|
||||||
|
except GitHubAuthError:
|
||||||
|
logger.warning(
|
||||||
|
"Re-auth still produced 401 reacting to issue comment %s",
|
||||||
|
comment_id,
|
||||||
|
)
|
||||||
|
reacted = False
|
||||||
if not reacted:
|
if not reacted:
|
||||||
logger.warning("Failed to react to GitHub issue comment %s", comment_id)
|
logger.warning("Failed to react to GitHub issue comment %s", comment_id)
|
||||||
|
|
||||||
|
|
@ -2194,9 +2255,15 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
||||||
else:
|
else:
|
||||||
prompt = build_github_issue_update_prompt(github_login, title, description)
|
prompt = build_github_issue_update_prompt(github_login, title, description)
|
||||||
else:
|
else:
|
||||||
comments = await fetch_issue_comments(
|
try:
|
||||||
repo_config, issue_number, token=github_token or app_token
|
comments = await fetch_issue_comments(
|
||||||
)
|
repo_config, issue_number, token=github_token or app_token
|
||||||
|
)
|
||||||
|
except GitHubAuthError:
|
||||||
|
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
|
||||||
|
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):
|
if comment_id and not any(item.get("comment_id") == comment_id for item in comments):
|
||||||
comments.append(
|
comments.append(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -651,9 +651,15 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N
|
||||||
async def fake_get_github_app_installation_token() -> str | None:
|
async def fake_get_github_app_installation_token() -> str | None:
|
||||||
return "app-token"
|
return "app-token"
|
||||||
|
|
||||||
async def fake_persist_encrypted_github_token(thread_id: str, token: str) -> str:
|
async def fake_get_github_app_installation_token_with_expiry() -> tuple[str | None, str | None]:
|
||||||
|
return "app-token", None
|
||||||
|
|
||||||
|
async def fake_persist_encrypted_github_token(
|
||||||
|
thread_id: str, token: str, *, expires_at: str | None = None
|
||||||
|
) -> str:
|
||||||
captured["persist_thread_id"] = thread_id
|
captured["persist_thread_id"] = thread_id
|
||||||
captured["persist_token"] = token
|
captured["persist_token"] = token
|
||||||
|
captured["persist_expires_at"] = expires_at
|
||||||
return "encrypted-token"
|
return "encrypted-token"
|
||||||
|
|
||||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||||
|
|
@ -680,6 +686,11 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webapp,
|
||||||
|
"get_github_app_installation_token_with_expiry",
|
||||||
|
fake_get_github_app_installation_token_with_expiry,
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "persist_encrypted_github_token", fake_persist_encrypted_github_token
|
webapp, "persist_encrypted_github_token", fake_persist_encrypted_github_token
|
||||||
)
|
)
|
||||||
|
|
@ -730,6 +741,9 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
async def fake_get_github_app_installation_token() -> str | None:
|
async def fake_get_github_app_installation_token() -> str | None:
|
||||||
return "app-token"
|
return "app-token"
|
||||||
|
|
||||||
|
async def fake_get_github_app_installation_token_with_expiry() -> tuple[str | None, str | None]:
|
||||||
|
return "app-token", None
|
||||||
|
|
||||||
async def fake_fetch_github_pr_metadata(
|
async def fake_fetch_github_pr_metadata(
|
||||||
pr_ref: GitHubPrRef, *, token: str
|
pr_ref: GitHubPrRef, *, token: str
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
|
@ -740,9 +754,12 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
"head": {"sha": "head-sha", "ref": "feature-branch"},
|
"head": {"sha": "head-sha", "ref": "feature-branch"},
|
||||||
}
|
}
|
||||||
|
|
||||||
async def fake_persist_encrypted_github_token(thread_id: str, token: str) -> str:
|
async def fake_persist_encrypted_github_token(
|
||||||
|
thread_id: str, token: str, *, expires_at: str | None = None
|
||||||
|
) -> str:
|
||||||
captured["persist_thread_id"] = thread_id
|
captured["persist_thread_id"] = thread_id
|
||||||
captured["persist_token"] = token
|
captured["persist_token"] = token
|
||||||
|
captured["persist_expires_at"] = expires_at
|
||||||
return "encrypted-token"
|
return "encrypted-token"
|
||||||
|
|
||||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||||
|
|
@ -769,6 +786,11 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webapp,
|
||||||
|
"get_github_app_installation_token_with_expiry",
|
||||||
|
fake_get_github_app_installation_token_with_expiry,
|
||||||
|
)
|
||||||
monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
|
monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "persist_encrypted_github_token", fake_persist_encrypted_github_token
|
webapp, "persist_encrypted_github_token", fake_persist_encrypted_github_token
|
||||||
|
|
|
||||||
330
tests/test_github_token_ttl.py
Normal file
330
tests/test_github_token_ttl.py
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
"""Tests for TTL + revocation handling on cached GitHub OAuth tokens.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- (a) expired-cache reads return None / fall through to re-auth
|
||||||
|
- (b) 401 on a downstream GitHub call invalidates the cached ciphertext and
|
||||||
|
triggers a fresh resolve in the webapp
|
||||||
|
- (c) ``publish_review`` invalidates the cached token and returns a clean
|
||||||
|
failure when GitHub responds 401
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.utils import github_comments, github_token
|
||||||
|
|
||||||
|
_TEST_FERNET_KEY = "GMI8FNqVnhFzVfKDUTpGAUq8a2cm14kU0SyXzMTM4Yc="
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _set_encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", _TEST_FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypted(token: str) -> str:
|
||||||
|
from agent.encryption import encrypt_token
|
||||||
|
|
||||||
|
return encrypt_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
# (a) expired-cache reads -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_expired_handles_iso_zulu_strings() -> None:
|
||||||
|
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat().replace("+00:00", "Z")
|
||||||
|
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat().replace("+00:00", "Z")
|
||||||
|
assert github_token._is_expired(past) is True
|
||||||
|
assert github_token._is_expired(future) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_expired_handles_unix_timestamps() -> None:
|
||||||
|
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
|
||||||
|
future = (datetime.now(UTC) + timedelta(hours=1)).timestamp()
|
||||||
|
assert github_token._is_expired(past) is True
|
||||||
|
assert github_token._is_expired(future) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_expired_treats_unparseable_as_not_expired() -> None:
|
||||||
|
assert github_token._is_expired(None) is False
|
||||||
|
assert github_token._is_expired("") is False
|
||||||
|
assert github_token._is_expired("not-a-date") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_github_token_returns_none_for_expired_run_metadata() -> None:
|
||||||
|
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||||
|
metadata = {
|
||||||
|
"github_token_encrypted": _encrypted("ghp_secret"),
|
||||||
|
"github_token_expires_at": past,
|
||||||
|
}
|
||||||
|
assert github_token.get_github_token({"metadata": metadata}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_github_token_returns_decrypted_for_fresh_metadata() -> None:
|
||||||
|
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||||
|
metadata = {
|
||||||
|
"github_token_encrypted": _encrypted("ghp_secret"),
|
||||||
|
"github_token_expires_at": future,
|
||||||
|
}
|
||||||
|
assert github_token.get_github_token({"metadata": metadata}) == "ghp_secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_github_token_returns_decrypted_when_no_expires_at() -> None:
|
||||||
|
metadata = {"github_token_encrypted": _encrypted("ghp_secret")}
|
||||||
|
assert github_token.get_github_token({"metadata": metadata}) == "ghp_secret"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_github_token_from_thread_skips_expired() -> None:
|
||||||
|
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {
|
||||||
|
"metadata": {
|
||||||
|
"github_token_encrypted": _encrypted("ghp_revoked"),
|
||||||
|
"github_token_expires_at": past,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(github_token, "client", fake_client):
|
||||||
|
token, encrypted, expires_at = await github_token.get_github_token_from_thread("tid")
|
||||||
|
assert token is None
|
||||||
|
assert encrypted is None
|
||||||
|
assert expires_at is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_github_token_from_thread_returns_fresh() -> None:
|
||||||
|
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
|
||||||
|
enc = _encrypted("ghp_live")
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {
|
||||||
|
"metadata": {
|
||||||
|
"github_token_encrypted": enc,
|
||||||
|
"github_token_expires_at": future,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.object(github_token, "client", fake_client):
|
||||||
|
token, encrypted, expires_at = await github_token.get_github_token_from_thread("tid")
|
||||||
|
assert token == "ghp_live"
|
||||||
|
assert encrypted == enc
|
||||||
|
assert expires_at == future
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalidate_cached_github_token_clears_metadata() -> None:
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
with patch.object(github_token, "client", fake_client):
|
||||||
|
await github_token.invalidate_cached_github_token("tid-42")
|
||||||
|
fake_client.threads.update.assert_awaited_once_with(
|
||||||
|
thread_id="tid-42",
|
||||||
|
metadata={"github_token_encrypted": None, "github_token_expires_at": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# (b) 401 on a downstream GitHub call -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _MockResponse:
|
||||||
|
def __init__(self, status_code: int, json_data: Any | None = None) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json = json_data or {}
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
|
||||||
|
class _MockHttpxClient:
|
||||||
|
def __init__(self, status_code: int, json_data: Any | None = None) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.json_data = json_data
|
||||||
|
self.posts: list[dict[str, Any]] = []
|
||||||
|
self.gets: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _MockHttpxClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url: str, **kwargs: Any) -> _MockResponse:
|
||||||
|
self.posts.append({"url": url, **kwargs})
|
||||||
|
return _MockResponse(self.status_code, self.json_data)
|
||||||
|
|
||||||
|
async def get(self, url: str, **kwargs: Any) -> _MockResponse:
|
||||||
|
self.gets.append({"url": url, **kwargs})
|
||||||
|
return _MockResponse(self.status_code, self.json_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_react_to_github_comment_raises_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
mock_client = _MockHttpxClient(status_code=401)
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: mock_client)
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
await github_comments.react_to_github_comment(
|
||||||
|
{"owner": "o", "name": "r"},
|
||||||
|
comment_id=1,
|
||||||
|
event_type="issue_comment",
|
||||||
|
token="revoked",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(github_token.GitHubAuthError):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_pr_comments_since_last_tag_raises_on_401(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
mock_client = _MockHttpxClient(status_code=401)
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: mock_client)
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
await github_comments.fetch_pr_comments_since_last_tag(
|
||||||
|
{"owner": "o", "name": "r"},
|
||||||
|
pr_number=42,
|
||||||
|
token="revoked",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(github_token.GitHubAuthError):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_issue_comments_raises_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
mock_client = _MockHttpxClient(status_code=401)
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: mock_client)
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
await github_comments.fetch_issue_comments(
|
||||||
|
{"owner": "o", "name": "r"},
|
||||||
|
issue_number=42,
|
||||||
|
token="revoked",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(github_token.GitHubAuthError):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
# (c) successful re-auth following stale-cache invalidation -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_github_pr_comment_invalidates_and_reauths_on_401(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""End-to-end check: a 401 on react triggers invalidate + re-resolve."""
|
||||||
|
from agent import webapp
|
||||||
|
|
||||||
|
invalidated: dict[str, int] = {"calls": 0}
|
||||||
|
resolves: list[str] = []
|
||||||
|
react_calls: list[str] = []
|
||||||
|
fetch_calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_invalidate(thread_id: str) -> None:
|
||||||
|
invalidated["calls"] += 1
|
||||||
|
|
||||||
|
tokens = iter(["stale-token", "fresh-token"])
|
||||||
|
|
||||||
|
async def fake_get_or_resolve(thread_id: str, email: str) -> str | None:
|
||||||
|
token = next(tokens)
|
||||||
|
resolves.append(token)
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def fake_react(
|
||||||
|
repo_config: dict[str, str],
|
||||||
|
comment_id: int,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
token: str,
|
||||||
|
pull_number: int | None = None,
|
||||||
|
node_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
react_calls.append(token)
|
||||||
|
if token == "stale-token":
|
||||||
|
raise github_comments.GitHubAuthError("revoked")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_fetch_pr_comments(
|
||||||
|
repo_config: dict[str, str], pr_number: int, *, token: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
fetch_calls.append(token)
|
||||||
|
return [
|
||||||
|
{"body": "@openswe please look", "author": "octo", "created_at": "2026-01-01T00:00:00Z"}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_extract_pr_context(
|
||||||
|
payload: dict[str, Any], event_type: str
|
||||||
|
) -> tuple[dict[str, str], int, str, str, str, int, str | None]:
|
||||||
|
return (
|
||||||
|
{"owner": "o", "name": "r"},
|
||||||
|
7,
|
||||||
|
"open-swe/00000000-0000-0000-0000-000000000001",
|
||||||
|
"octo",
|
||||||
|
"https://github.com/o/r/pull/7",
|
||||||
|
42,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_trigger_or_queue_run(*args: Any, **kwargs: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context)
|
||||||
|
monkeypatch.setattr(webapp, "_get_or_resolve_thread_github_token", fake_get_or_resolve)
|
||||||
|
monkeypatch.setattr(webapp, "invalidate_cached_github_token", fake_invalidate)
|
||||||
|
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react)
|
||||||
|
monkeypatch.setattr(webapp, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments)
|
||||||
|
monkeypatch.setattr(webapp, "_trigger_or_queue_run", fake_trigger_or_queue_run)
|
||||||
|
monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {"octo": "octo@example.com"})
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
webapp.process_github_pr_comment(
|
||||||
|
{"sender": {"login": "octo", "id": 1}},
|
||||||
|
"issue_comment",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert invalidated["calls"] == 1
|
||||||
|
assert resolves == ["stale-token", "fresh-token"]
|
||||||
|
assert react_calls == ["stale-token", "fresh-token"]
|
||||||
|
assert fetch_calls == ["fresh-token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_review_invalidates_cached_token_on_401(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
publish_review_module = importlib.import_module("agent.tools.publish_review")
|
||||||
|
|
||||||
|
invalidated: dict[str, int] = {"calls": 0}
|
||||||
|
|
||||||
|
async def fake_invalidate(thread_id: str) -> None:
|
||||||
|
invalidated["calls"] += 1
|
||||||
|
invalidated["thread_id"] = thread_id # type: ignore[assignment]
|
||||||
|
|
||||||
|
async def fake_publish(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
raise github_token.GitHubAuthError("401 from PR review")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
publish_review_module,
|
||||||
|
"get_config",
|
||||||
|
lambda: {
|
||||||
|
"configurable": {
|
||||||
|
"repo": {"owner": "o", "name": "r"},
|
||||||
|
"pr_number": 7,
|
||||||
|
"head_sha": "deadbeef",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(publish_review_module, "get_github_token", lambda: "revoked-token")
|
||||||
|
monkeypatch.setattr(publish_review_module, "invalidate_cached_github_token", fake_invalidate)
|
||||||
|
monkeypatch.setattr(publish_review_module, "_publish_review_async", fake_publish)
|
||||||
|
monkeypatch.setattr(publish_review_module, "get_thread_id_from_runtime", lambda: "thread-xyz")
|
||||||
|
|
||||||
|
result = publish_review_module.publish_review()
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "401" in result["error"]
|
||||||
|
assert invalidated["calls"] == 1
|
||||||
|
assert invalidated.get("thread_id") == "thread-xyz"
|
||||||
|
|
@ -210,7 +210,7 @@ class TestRefreshProxyOnSandboxReuse:
|
||||||
patch(
|
patch(
|
||||||
"agent.server.resolve_github_token",
|
"agent.server.resolve_github_token",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("ghp", "enc"),
|
return_value=("ghp", "enc", None),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.get_sandbox_id_from_metadata",
|
"agent.server.get_sandbox_id_from_metadata",
|
||||||
|
|
@ -259,7 +259,7 @@ class TestRefreshProxyOnSandboxReuse:
|
||||||
patch(
|
patch(
|
||||||
"agent.server.resolve_github_token",
|
"agent.server.resolve_github_token",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("ghp", "enc"),
|
return_value=("ghp", "enc", None),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.get_sandbox_id_from_metadata",
|
"agent.server.get_sandbox_id_from_metadata",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async def test_reviewer_uses_cached_thread_token_for_slack_review_request() -> N
|
||||||
patch(
|
patch(
|
||||||
"agent.reviewer.get_github_token_from_thread",
|
"agent.reviewer.get_github_token_from_thread",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("app-token", "encrypted-token"),
|
return_value=("app-token", "encrypted-token", None),
|
||||||
) as mock_get_thread_token,
|
) as mock_get_thread_token,
|
||||||
patch("agent.reviewer.resolve_github_token", new_callable=AsyncMock) as mock_resolve_token,
|
patch("agent.reviewer.resolve_github_token", new_callable=AsyncMock) as mock_resolve_token,
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,11 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="t",
|
return_value="t",
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"agent.webapp.get_github_app_installation_token_with_expiry",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=("t", None),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp._fetch_open_pr_for_branch",
|
"agent.webapp._fetch_open_pr_for_branch",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue