mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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
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:
parent
0651de2ebf
commit
2fb1122630
8 changed files with 262 additions and 23 deletions
|
|
@ -20,7 +20,7 @@ from ..utils.slack import get_slack_permalink
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
_USER_TOKEN_SOURCES = ("slack", "dashboard")
|
||||
_USER_TOKEN_SOURCES = ("slack", "linear", "dashboard")
|
||||
_REFERENCES_HEADING = "## References"
|
||||
_ACCESS_FAILURE_CODE = "github_app_access_missing_or_repo_not_found"
|
||||
_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.
|
||||
|
||||
DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
|
||||
installation token) for every source, so Slack/dashboard PRs are attributed
|
||||
to the app — matching GitHub-issue runs and making the self-review 422
|
||||
impossible by construction. OPT-IN: when the triggering user's profile has
|
||||
``author_prs_as_user: true``, open Slack/dashboard PRs as that user (their
|
||||
per-user OAuth token, resolved by login from the dashboard OAuth store).
|
||||
installation token) for every source, so PRs are attributed to the app —
|
||||
matching GitHub-issue runs and making the self-review 422 impossible by
|
||||
construction. OPT-IN: when the triggering user's profile has
|
||||
``author_prs_as_user: true``, open Slack/Linear/dashboard PRs as that user
|
||||
(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
|
||||
metadata: Slack thread ids are shared across a conversation, so a cached
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""Resolve a GitHub token from the run config based on the source.
|
||||
|
||||
Routes to the correct auth method depending on whether the run was
|
||||
triggered from GitHub (login-based) or Linear/Slack (email-based).
|
||||
Routes to the correct auth method depending on the source. Sources that
|
||||
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
|
||||
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")
|
||||
|
||||
# DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token,
|
||||
# so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]`
|
||||
# (deterministic; matches GitHub-issue runs; eliminates the self-review 422).
|
||||
# OPT-IN: a profile with `author_prs_as_user: true` restores the per-user OAuth
|
||||
# token so the run is attributed to the triggering user.
|
||||
# DEFAULT: Slack/Linear/dashboard/schedule runs use the GitHub App installation
|
||||
# token, so all git/gh operations + the PR come in as the app
|
||||
# `seahaven-openswe[bot]` (deterministic; matches GitHub-issue runs; eliminates
|
||||
# the self-review 422). OPT-IN: a profile with `author_prs_as_user: true`
|
||||
# restores the per-user OAuth token so the run is attributed to the triggering
|
||||
# user.
|
||||
if (
|
||||
source in ("slack", "dashboard", "schedule")
|
||||
source in ("slack", "linear", "dashboard", "schedule")
|
||||
and isinstance(github_login, str)
|
||||
and github_login.strip()
|
||||
):
|
||||
|
|
|
|||
|
|
@ -40,12 +40,19 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
|||
if not full_issue:
|
||||
full_issue = issue_data
|
||||
|
||||
user_email = 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", {})
|
||||
if comment_author:
|
||||
user_email = comment_author.get("email")
|
||||
actor_email = comment_author.get("email")
|
||||
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:
|
||||
creator = full_issue.get("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}"
|
||||
)
|
||||
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
|
||||
if image_urls:
|
||||
image_urls = webapp.dedupe_urls(image_urls)
|
||||
linear_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)
|
||||
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
||||
if not webapp.model_supports_images(resolved_model_id):
|
||||
fallback_model_id, fallback_effort = webapp.default_vision_model_pair()
|
||||
webapp.logger.info(
|
||||
|
|
@ -213,6 +224,8 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
|||
"user_email": user_email,
|
||||
"source": "linear",
|
||||
}
|
||||
if mapped_login:
|
||||
configurable["github_login"] = mapped_login
|
||||
if image_model_override:
|
||||
configurable["agent_model_id"] = image_model_override[0]
|
||||
configurable["agent_effort"] = image_model_override[1]
|
||||
|
|
@ -221,6 +234,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
|||
thread_id,
|
||||
source="linear",
|
||||
repo_config=repo_config,
|
||||
github_login=mapped_login or "",
|
||||
user_email=user_email or "",
|
||||
title=title or identifier or "Linear issue",
|
||||
source_context={"linear_issue": configurable["linear_issue"]},
|
||||
|
|
|
|||
|
|
@ -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": "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": "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": "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"}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
@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(
|
||||
monkeypatch: pytest.MonkeyPatch, source: str
|
||||
) -> None:
|
||||
|
|
|
|||
116
tests/test_linear_webhook_author.py
Normal file
116
tests/test_linear_webhook_author.py
Normal 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"] == ""
|
||||
|
|
@ -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:
|
||||
_set_config(monkeypatch, {"source": "github", "github_login": "johannes117"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue