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)
This commit is contained in:
amoussa1229 2026-07-13 17:17:55 +00:00
parent f7119b7be1
commit 4fc7d0c022
8 changed files with 233 additions and 21 deletions

View file

@ -19,7 +19,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"
@ -27,11 +27,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

@ -164,13 +164,16 @@ 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 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.
mapped_login = await webapp.resolve_login_from_email_async(user_email) if user_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 +216,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 +226,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": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"} {"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "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": "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

@ -56,6 +56,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval | | `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
| `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 |
| `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 |
@ -91,7 +92,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `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 |
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution | | `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks | | `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | 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) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user |
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope | | `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope |
| `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 |

View file

@ -183,7 +183,58 @@ 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)
@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

@ -195,6 +195,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"})