feat(open-swe): open Linear-triggered PRs as the triggering user (#179)
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 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>
This commit is contained in:
seahaven-openswe[bot] 2026-07-13 15:17:00 -04:00 • committed by GitHub
parent 0651de2ebf
commit 2fb1122630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 262 additions and 23 deletions

View file

@ -20,7 +20,7 @@ from ..utils.slack import get_slack_permalink
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GITHUB_API = "https://api.github.com" GITHUB_API = "https://api.github.com"
_USER_TOKEN_SOURCES = ("slack", "dashboard") _USER_TOKEN_SOURCES = ("slack", "linear", "dashboard")
_REFERENCES_HEADING = "## References" _REFERENCES_HEADING = "## References"
_ACCESS_FAILURE_CODE = "github_app_access_missing_or_repo_not_found" _ACCESS_FAILURE_CODE = "github_app_access_missing_or_repo_not_found"
_BRANCH_FAILURE_CODE = "github_pr_branch_not_visible" _BRANCH_FAILURE_CODE = "github_pr_branch_not_visible"
@ -31,11 +31,12 @@ async def _resolve_pr_author_token() -> tuple[str | None, str]:
"""Return ``(token, kind)`` for opening the PR. """Return ``(token, kind)`` for opening the PR.
DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
installation token) for every source, so Slack/dashboard PRs are attributed installation token) for every source, so PRs are attributed to the app —
to the app — matching GitHub-issue runs and making the self-review 422 matching GitHub-issue runs and making the self-review 422 impossible by
impossible by construction. OPT-IN: when the triggering user's profile has construction. OPT-IN: when the triggering user's profile has
``author_prs_as_user: true``, open Slack/dashboard PRs as that user (their ``author_prs_as_user: true``, open Slack/Linear/dashboard PRs as that user
per-user OAuth token, resolved by login from the dashboard OAuth store). (their per-user OAuth token, resolved by login from the dashboard OAuth
store).
The token is resolved by login rather than read from the shared thread The token is resolved by login rather than read from the shared thread
metadata: Slack thread ids are shared across a conversation, so a cached metadata: Slack thread ids are shared across a conversation, so a cached

View file

@ -423,8 +423,10 @@ async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | No
async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str | None]: async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[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 the source. Sources that
triggered from GitHub (login-based) or Linear/Slack (email-based). carry a mapped GitHub login (Slack, Linear, dashboard, schedule) resolve a
per-user OAuth token from the dashboard store; GitHub runs are login-based;
otherwise resolution falls back to email-based auth.
In bot-token-only mode (LANGSMITH_API_KEY_PROD set without In bot-token-only mode (LANGSMITH_API_KEY_PROD set without
X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used
@ -441,13 +443,14 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
github_login = configurable.get("github_login") github_login = configurable.get("github_login")
# DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token, # DEFAULT: Slack/Linear/dashboard/schedule runs use the GitHub App installation
# so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]` # token, so all git/gh operations + the PR come in as the app
# (deterministic; matches GitHub-issue runs; eliminates the self-review 422). # `seahaven-openswe[bot]` (deterministic; matches GitHub-issue runs; eliminates
# OPT-IN: a profile with `author_prs_as_user: true` restores the per-user OAuth # the self-review 422). OPT-IN: a profile with `author_prs_as_user: true`
# token so the run is attributed to the triggering user. # restores the per-user OAuth token so the run is attributed to the triggering
# user.
if ( if (
source in ("slack", "dashboard", "schedule") source in ("slack", "linear", "dashboard", "schedule")
and isinstance(github_login, str) and isinstance(github_login, str)
and github_login.strip() and github_login.strip()
): ):

View file

@ -40,12 +40,19 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
if not full_issue: if not full_issue:
full_issue = issue_data full_issue = issue_data
user_email = None
user_name = None user_name = None
# Actor email for token attribution: restricted to the comment author only.
# The creator/assignee fallback chain is intentionally excluded here so a PR
# is never opened as a non-actor.
actor_email = None
comment_author = issue_data.get("comment_author", {}) comment_author = issue_data.get("comment_author", {})
if comment_author: if comment_author:
user_email = comment_author.get("email") actor_email = comment_author.get("email")
user_name = comment_author.get("name") user_name = comment_author.get("name")
# User email with full fallback chain for display, model selection, and
# @mention instructions.
user_email = actor_email
if not user_email: if not user_email:
creator = full_issue.get("creator", {}) creator = full_issue.get("creator", {})
if creator: if creator:
@ -164,13 +171,17 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
f"When you're done, commit and push your changes. {tag_instruction}" f"When you're done, commit and push your changes. {tag_instruction}"
) )
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
# Resolve the GitHub login from the actor's Linear email via the same
# user-mapping store Slack uses, so PRs open *as the triggering user* and the
# thread is tagged for the dashboard. Restricted to the comment author so
# token attribution never falls back to creator/assignee.
mapped_login = await webapp.resolve_login_from_email_async(actor_email) if actor_email else None
image_model_override: tuple[str, str] | None = None image_model_override: tuple[str, str] | None = None
if image_urls: if image_urls:
image_urls = webapp.dedupe_urls(image_urls) image_urls = webapp.dedupe_urls(image_urls)
linear_login = ( resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
await webapp.resolve_login_from_email_async(user_email) if user_email else None
)
resolved_model_id = await webapp.resolve_agent_model_id(linear_login)
if not webapp.model_supports_images(resolved_model_id): if not webapp.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = webapp.default_vision_model_pair() fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
webapp.logger.info( webapp.logger.info(
@ -213,6 +224,8 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
"user_email": user_email, "user_email": user_email,
"source": "linear", "source": "linear",
} }
if mapped_login:
configurable["github_login"] = mapped_login
if image_model_override: if image_model_override:
configurable["agent_model_id"] = image_model_override[0] configurable["agent_model_id"] = image_model_override[0]
configurable["agent_effort"] = image_model_override[1] configurable["agent_effort"] = image_model_override[1]
@ -221,6 +234,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
thread_id, thread_id,
source="linear", source="linear",
repo_config=repo_config, repo_config=repo_config,
github_login=mapped_login or "",
user_email=user_email or "", user_email=user_email or "",
title=title or identifier or "Linear issue", title=title or identifier or "Linear issue",
source_context={"linear_issue": configurable["linear_issue"]}, source_context={"linear_issue": configurable["linear_issue"]},

View file

@ -73,7 +73,7 @@
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "landed", "reason": "Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5.", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-10T17:07:19Z"} {"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "landed", "reason": "Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5.", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-10T17:07:19Z"}
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"} {"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "landed", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-13T17:06:27Z"} {"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "landed", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-13T17:06:27Z"}
{"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"} {"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "landed", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "feature/port-linear-pr-author", "local_sha": null, "updated": "2026-07-13T17:17:12Z"}
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "landed", "reason": "Ported to dev via feat/thread-sandbox-id-sidebar.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-10T16:48:54Z"} {"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "landed", "reason": "Ported to dev via feat/thread-sandbox-id-sidebar.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-10T16:48:54Z"}
{"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"} {"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
{"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"} {"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}

View file

@ -58,6 +58,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Landed | Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5. | fable-admin-toggle | | `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Landed | Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5. | fable-admin-toggle |
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval | | `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Landed | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks | | `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Landed | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Landed | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | feature/port-linear-pr-author |
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Landed | Ported to dev via feat/thread-sandbox-id-sidebar. | dashboard-ui | | `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Landed | Ported to dev via feat/thread-sandbox-id-sidebar. | dashboard-ui |
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents | | `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
| `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools | | `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools |
@ -92,7 +93,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `c0a7e93e` | #1691 | fix: reconnect sandbox backend on resumed runs (#1691) | Deferred | reconnect proxy (has_backend/reconnect); assumes async create_sandbox | sandbox-refactor | | `c0a7e93e` | #1691 | fix: reconnect sandbox backend on resumed runs (#1691) | Deferred | reconnect proxy (has_backend/reconnect); assumes async create_sandbox | sandbox-refactor |
| `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor | | `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor |
| `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor | | `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor |
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user |
| `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking | | `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking |
| `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker | | `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker |
| `62e0ca2d` | #1709 | fix: stale admin model defaults after model upgrades (#1709) | Deferred | stale admin model-default cleanup in team_settings after model upgrades; applies to fork's default-model resolution. | model-picker | | `62e0ca2d` | #1709 | fix: stale admin model defaults after model upgrades (#1709) | Deferred | stale admin model-default cleanup in team_settings after model upgrades; applies to fork's default-model resolution. | model-picker |

View file

@ -183,7 +183,77 @@ def test_resolve_github_token_slack_optin_no_token_falls_back_to_bot_in_bot_only
assert (token, expires_at) == ("bot-tok", None) assert (token, expires_at) == ("bot-tok", None)
@pytest.mark.parametrize("source", ["github", "linear"]) def _linear_config(github_login: str | None = "mason-gh") -> dict:
configurable: dict = {
"source": "linear",
"user_email": "mason@example.com",
"thread_id": "t1",
}
if github_login is not None:
configurable["github_login"] = github_login
return {"configurable": configurable}
def test_resolve_github_token_linear_optin_uses_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, expires_at = asyncio.run(auth.resolve_github_token(_linear_config(), "t1"))
assert token == "user-tok"
assert expires_at == "2099-01-01T00:00:00Z"
def test_resolve_github_token_linear_optin_no_token_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_linear_config(), "t1"))
def test_resolve_github_token_linear_optin_no_token_falls_back_to_bot_in_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):
return ("bot-tok", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, expires_at = asyncio.run(auth.resolve_github_token(_linear_config(), "t1"))
assert (token, expires_at) == ("bot-tok", None)
def test_resolve_github_token_linear_defaults_to_bot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# DEFAULT (no author_prs_as_user opt-in): linear runs author as the app bot,
# even when a valid per-user token and mapped login exist — so PRs can never
# be opened as a non-actor (creator/assignee).
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=False)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
async def fake_bot(thread_id: str):
return ("bot-tok", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, _ = asyncio.run(auth.resolve_github_token(_linear_config(), "t1"))
assert token == "bot-tok"
@pytest.mark.parametrize("source", ["github"])
def test_resolve_github_token_bot_only_mode_non_slack_uses_bot( def test_resolve_github_token_bot_only_mode_non_slack_uses_bot(
monkeypatch: pytest.MonkeyPatch, source: str monkeypatch: pytest.MonkeyPatch, source: str
) -> None: ) -> None:

View file

@ -0,0 +1,116 @@
"""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"] == ""

View file

@ -196,6 +196,41 @@ def test_uses_user_token_for_slack_with_optin(monkeypatch: pytest.MonkeyPatch) -
} }
def test_uses_user_token_for_linear_with_optin(monkeypatch: pytest.MonkeyPatch) -> None:
_set_config(monkeypatch, {"source": "linear", "github_login": "johannes117"})
_set_profile(monkeypatch, author_prs_as_user=True)
from agent.dashboard import profiles
async def fake_user_token(login: str, **_kw: Any) -> str | None:
assert login == "johannes117"
return "user-tok"
monkeypatch.setattr(profiles, "get_valid_access_token", fake_user_token)
async def fail_bot() -> str | None:
raise AssertionError("bot token should not be used when a user token exists")
monkeypatch.setattr(opr, "get_github_app_installation_token", fail_bot)
client = _FakeClient(
post=_FakeResponse(
201,
{"html_url": "https://x/pull/1", "number": 1, "user": {"login": "johannes117"}},
)
)
_install_client(monkeypatch, client)
result = _open()
assert result["success"] is True
assert result["created"] is True
assert result["url"] == "https://x/pull/1"
assert result["author"] == "johannes117"
assert result["token_kind"] == "user"
assert client.post_calls[0]["headers"]["Authorization"] == "Bearer user-tok"
def test_falls_back_to_bot_for_github_source(monkeypatch: pytest.MonkeyPatch) -> None: def test_falls_back_to_bot_for_github_source(monkeypatch: pytest.MonkeyPatch) -> None:
_set_config(monkeypatch, {"source": "github", "github_login": "johannes117"}) _set_config(monkeypatch, {"source": "github", "github_login": "johannes117"})