open-swe/tests/test_linear_webhook_author.py
seahaven-openswe[bot] 2fb1122630
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
feat(open-swe): open Linear-triggered PRs as the triggering user (#179)
* feat: open Linear-triggered PRs as the triggering user

Add 'linear' to the set of sources that carry a mapped GitHub login
(Slack, Linear, dashboard, schedule), so Linear-triggered runs resolve
the per-user OAuth token when the author_prs_as_user profile flag is
enabled. Previously only Slack and dashboard runs could open PRs as the
user; Linear runs always used the bot token.

The Linear webhook now resolves the GitHub login from the Linear email
via the same user-mapping store Slack uses, and passes it through the
run configurable and thread owner metadata.

Refs: 5003c953 (upstream #1683)

* fix(open-swe): restrict Linear token attribution to comment author only

Split actor_email (comment_author only, feeds github_login for token
attribution) from user_email (full fallback chain, for display/model).
This ensures a PR is never opened as a non-actor (creator/assignee).

Add test_resolve_github_token_linear_defaults_to_bot to lock the
security-critical default: Linear + mapped login + no opt-in → bot.

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-13 15:17:00 -04:00

116 lines
4 KiB
Python

"""Tests for Linear webhook PR author linking (reuse of the Slack user mapping)."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, patch
from agent.webhooks import linear as linear_webhook
def _full_issue(*, user_email: str | None = "zhen@example.com", user_name: str = "Zhen") -> dict:
return {
"id": "issue-1",
"title": "Link Linear PRs to author",
"description": "Do the thing",
"identifier": "OS-42",
"url": "https://linear.app/x/issue/OS-42",
"creator": {"email": user_email, "name": user_name},
"comments": {"nodes": []},
}
def _issue_data(*, user_email: str | None, user_name: str = "Zhen") -> dict:
# linear_webhook attaches comment_author to the issue dict before dispatch.
data = _full_issue(user_email=user_email, user_name=user_name)
data["comment_author"] = {"email": user_email, "name": user_name}
return data
def _run_process(issue_data: dict, repo_config: dict[str, str]) -> tuple[dict, dict, str | None]:
captured: dict[str, Any] = {}
async def fake_dispatch(
thread_id, content, configurable, *, source, metadata=None, client=None
):
captured["configurable"] = configurable
return {"run_id": "run-1"}
async def fake_upsert(
thread_id,
*,
source,
repo_config=None,
github_login="",
user_email="",
title="",
source_context=None,
):
captured["upsert"] = {"github_login": github_login, "user_email": user_email}
return None
async def fake_resolve_login(email):
captured["resolved_email"] = email
return "zhen" if email == "zhen@example.com" else None
with (
patch.object(linear_webhook.webapp, "react_to_linear_comment", new_callable=AsyncMock),
patch.object(
linear_webhook.webapp, "generate_thread_id_from_issue", return_value="thread-1"
),
patch.object(
linear_webhook.webapp,
"fetch_linear_issue_details",
new_callable=AsyncMock,
return_value=_full_issue(user_email=issue_data.get("comment_author", {}).get("email")),
),
patch.object(
linear_webhook.webapp, "resolve_login_from_email_async", side_effect=fake_resolve_login
),
patch.object(linear_webhook.webapp, "dispatch_agent_run", side_effect=fake_dispatch),
patch.object(
linear_webhook.webapp, "upsert_agent_thread_owner_metadata", side_effect=fake_upsert
),
patch.object(linear_webhook.webapp, "post_linear_trace_comment", new_callable=AsyncMock),
):
asyncio.run(linear_webhook.process_linear_issue(issue_data, repo_config))
return (
captured.get("configurable", {}),
captured.get("upsert", {}),
captured.get("resolved_email"),
)
def test_linear_configurable_carries_github_login() -> None:
configurable, _upsert, resolved_email = _run_process(
_issue_data(user_email="zhen@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert resolved_email == "zhen@example.com"
assert configurable["source"] == "linear"
assert configurable["github_login"] == "zhen"
assert configurable["user_email"] == "zhen@example.com"
def test_linear_upsert_tags_thread_with_login() -> None:
_configurable, upsert, _email = _run_process(
_issue_data(user_email="zhen@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert upsert["github_login"] == "zhen"
assert upsert["user_email"] == "zhen@example.com"
def test_linear_omits_login_when_unmapped() -> None:
configurable, upsert, resolved_email = _run_process(
_issue_data(user_email="nobody@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert resolved_email == "nobody@example.com"
assert "github_login" not in configurable
assert upsert["github_login"] == ""