diff --git a/agent/webhooks/linear.py b/agent/webhooks/linear.py index 8547bc02..36ceb1ab 100644 --- a/agent/webhooks/linear.py +++ b/agent/webhooks/linear.py @@ -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: @@ -165,10 +172,11 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 ) 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 + # 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: diff --git a/tests/test_auth_sources.py b/tests/test_auth_sources.py index f7ff362a..fc3d0282 100644 --- a/tests/test_auth_sources.py +++ b/tests/test_auth_sources.py @@ -234,6 +234,25 @@ def test_resolve_github_token_linear_optin_no_token_falls_back_to_bot_in_bot_onl 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