From b6f6badde3a270c698a89527c45a7bc48b5cade4 Mon Sep 17 00:00:00 2001 From: Joseph Sawaya Date: Mon, 29 Jun 2026 11:10:34 -0400 Subject: [PATCH 1/9] chore(pr iteration): GitHub app permission nudge backend logic for commenting on autofix PRs during PR iteration when tool calls fail potentially due to out of date github app permissions logic for passing "warnings" back from group ai autofix GET endpoint specifically for failed tool calls potentially due to out of date github app permissions --- src/sentry/seer/autofix/github_perms.py | 221 ++++++++++++++++++ src/sentry/seer/autofix/on_completion_hook.py | 70 ++++++ src/sentry/seer/autofix/types.py | 20 +- src/sentry/seer/endpoints/group_ai_autofix.py | 19 ++ .../sentry/seer/autofix/test_github_perms.py | 85 +++++++ .../seer/autofix/test_on_completion_hook.py | 142 +++++++++++ .../seer/endpoints/test_group_ai_autofix.py | 61 +++++ 7 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 src/sentry/seer/autofix/github_perms.py create mode 100644 tests/sentry/seer/autofix/test_github_perms.py diff --git a/src/sentry/seer/autofix/github_perms.py b/src/sentry/seer/autofix/github_perms.py new file mode 100644 index 000000000000..e61e5fb03ae9 --- /dev/null +++ b/src/sentry/seer/autofix/github_perms.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sentry.constants import ObjectStatus +from sentry.integrations.services.integration import RpcIntegration, integration_service +from sentry.integrations.utils.github_permissions import get_missing_github_app_permissions +from sentry.models.organization import Organization +from sentry.models.repository import Repository + +if TYPE_CHECKING: + from sentry.seer.agent.client_models import MemoryBlock, SeerRunState, ToolCall + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class MissingGithubPermissions: + integration: RpcIntegration + # Empty when the installation has every required permission. + missing_scopes: list[str] + + @property + def installation_id(self) -> str: + """GitHub App installation id (Integration.external_id).""" + return str(self.integration.external_id) + + +def get_github_missing_permissions(integration_id: int) -> MissingGithubPermissions | None: + """Required GitHub App permissions the installation for `integration_id` is + missing. Returns None if the integration no longer exists.""" + integration = integration_service.get_integration(integration_id=integration_id) + if integration is None: + return None + + missing = get_missing_github_app_permissions(integration.metadata) + return MissingGithubPermissions( + integration=integration, + missing_scopes=[permission["expected"]["scope"] for permission in (missing or [])], + ) + + +_SEER_GITHUB_PROVIDER = "integrations:github" + +# Key set in a tool result's ToolLink.params when the tool call errored (mirrors +# seer's ERROR_KEY in seer.automation.explorer.models). +_TOOL_ERROR_KEY = "is_error" + + +def blocks_have_failed_tool_call(blocks: Iterable[MemoryBlock]) -> bool: + """True if any tool call in the given blocks errored (ToolLink.params[is_error]).""" + for block in blocks: + for link in block.tool_links or []: + if link is not None and link.params.get(_TOOL_ERROR_KEY) is True: + return True + return False + + +def _failed_tool_calls(block: MemoryBlock) -> Iterator[ToolCall]: + """The ToolCalls in `block` whose execution errored. + + tool_links is index-aligned with tool_results (see seer's explorer_agent), + and each tool_result carries the id of the tool_call it answered, so a failed + link at index j maps back to its originating tool_call. + """ + links = block.tool_links or [] + results = block.tool_results or [] + calls_by_id = {call.id: call for call in (block.message.tool_calls or []) if call.id} + for i, link in enumerate(links): + if link is None or link.params.get(_TOOL_ERROR_KEY) is not True: + continue + result = results[i] if i < len(results) else None + if result is None: + continue + call = calls_by_id.get(result.tool_call_id) + if call is not None: + yield call + + +def _repo_name_from_tool_call(call: ToolCall) -> str | None: + """The repo a tool call targeted, from its `repo_name` arg (None if absent).""" + try: + args = json.loads(call.args) + except (json.JSONDecodeError, TypeError): + return None + + if not isinstance(args, dict): + return None + + repo_name = args.get("repo_name") + return repo_name if isinstance(repo_name, str) and repo_name else None + + +def repos_with_failed_tool_calls(blocks: Iterable[MemoryBlock]) -> set[str]: + """Repo names that a failed tool call in the given blocks was made against. + + Answers: "the tool calls that failed were made against what repo?" + + Limitation: a failure is only attributed to a repo when the tool call carries + a `repo_name` arg. Errored tool responses drop their metadata (params is just + {is_error: True}), so we recover the repo from the call args instead — which + only works for repo-scoped tools. That's fine here: we're building this for + the PR context tools (summarize_failed_ci_logs, get_pr_*), which all take + `repo_name` as a required arg. A failed non-repo tool yields no repo. + """ + repos: set[str] = set() + for block in blocks: + for call in _failed_tool_calls(block): + repo_name = _repo_name_from_tool_call(call) + if repo_name: + repos.add(repo_name) + return repos + + +def get_out_of_date_github_permissions( + organization: Organization, blocks: Sequence[MemoryBlock] +) -> dict[str, MissingGithubPermissions]: + """ + An objective of this function is to only return repos that we know we should + notify on, since we likely ran into a failure with the github app permissions for that + repo. + + We don't want to surface out of date github permissions for a repo that we didn't + fail a tool call on, since we don't want to comment on the PR if we don't have a + good reason to. + + This is only relevant when it comes to notifying the user that there are out of date + github app permissions by commenting on the PR. + + Pseudocode: + + repos = {} + # 1. Which repos did a failed tool call target? (repos_with_failed_tool_calls) + for block in blocks: + for call in block.tool_calls: + if not call.failed: + continue + repo = json.loads(call.args).get("repo_name") + + # all the tools we care about commenting on, have the repo name as an arg + # so we should only hit this case if the tool call is unrelated to github app + # perms + if repo: + repos.add(repo) + + if not repos: + return {} + + # 2. Map those repo names to their GitHub integration (org-scoped, active). + for (repo, integration_id) in Repository.filter(org, github, active, name in repos): + # 3. Ask GitHub which required app permissions that install is missing. + perms = get_github_missing_permissions(integration_id) + if perms.missing_scopes: + result[repo] = perms + + return result + """ + repo_names = repos_with_failed_tool_calls(blocks) + if not repo_names: + return {} + + # Org-scoped so a run can only surface permissions for repos in its own org. + repo_integration_ids = Repository.objects.filter( + organization_id=organization.id, + provider=_SEER_GITHUB_PROVIDER, + name__in=repo_names, + status=ObjectStatus.ACTIVE, + ).values_list("name", "integration_id") + + missing_by_repo: dict[str, MissingGithubPermissions] = {} + for repo_name, integration_id in repo_integration_ids: + if not isinstance(integration_id, int): + continue + + perms = get_github_missing_permissions(integration_id) + if perms is not None and perms.missing_scopes: + missing_by_repo[repo_name] = perms + + return missing_by_repo + + +def comment_on_out_of_date_github_permissions( + organization: Organization, + state: SeerRunState, + missing_by_repo: Mapping[str, MissingGithubPermissions], +) -> list[str]: + """Post an issue comment on each repo's PR explaining that the GitHub App + installation is missing permissions Seer needs, linking the user to accept + them. Returns the repo names a comment was successfully posted for. + """ + commented: list[str] = [] + for repo_name, info in missing_by_repo.items(): + pr_state = state.repo_pr_states.get(repo_name) + if pr_state is None or pr_state.pr_number is None: + continue + + integration = info.integration + url = f"https://github.com/settings/installations/{info.installation_id}/permissions/update" + body = ( + "⚠️ **Seer needs additional GitHub permissions**\n\n" + "A Seer autofix tool failed because the Sentry GitHub App installation is " + "missing permissions. For the best experience using Seer, please review and " + f"accept the updated permissions: {url}" + ) + try: + client = integration.get_installation(organization_id=organization.id).get_client() + client.create_comment(repo_name, str(pr_state.pr_number), {"body": body}) + except Exception: + logger.exception( + "autofix.permissions_comment.post_failed", + extra={"organization_id": organization.id, "repo_name": repo_name}, + ) + continue + + commented.append(repo_name) + + return commented diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 45ec4146e8d3..8d31d9f5ac67 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -20,6 +20,7 @@ from sentry.seer.autofix.autofix_agent import ( STEP_CONFIGS, AutofixStep, + get_iterations, get_latest_iteration_index, trigger_autofix_agent, trigger_coding_agent_handoff, @@ -27,6 +28,12 @@ ) from sentry.seer.autofix.coding_agent import IntegrationNotFound from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.github_perms import ( + blocks_have_failed_tool_call, + comment_on_out_of_date_github_permissions, + get_out_of_date_github_permissions, + repos_with_failed_tool_calls, +) from sentry.seer.autofix.introspection import ( IntrospectionDecision, introspect_code_changes, @@ -113,9 +120,72 @@ def execute(cls, organization: Organization, run_id: int) -> None: # Send webhook for the completed step cls._send_step_webhook(organization, run_id, state, group) + # When a tool failed because the GitHub App installation is missing + # permissions the user needs to re-accept, comment on the affected PRs + # so the user knows to update them (at most once per repo per run). + cls._maybe_comment_on_missing_permissions(organization, run_id, state) + # Continue the automated pipeline if stopping_point hasn't been reached cls._maybe_continue_pipeline(organization, run_id, state, group) + @classmethod + def _maybe_comment_on_missing_permissions( + cls, + organization: Organization, + run_id: int, + state: SeerRunState, + ) -> None: + # Comment on a PR the first time a tool call fails for it since PRs were + # created — the first failing iteration touching that repo is our "first + # time" signal, so we don't need to persist whether we've commented. + # This is per-repo: a repo that already had a failing iteration is + # skipped, while a repo hitting its first failure now is commented on. + # Failures before PR creation (e.g. in code_changes) are ignored. + iterations = get_iterations(state) + if not iterations: + return + + # Only proceed when the latest iteration failed — that's when a repo can + # be hitting its first failure. + *earlier, latest = iterations + if not blocks_have_failed_tool_call(latest.blocks): + return + + missing_by_repo = get_out_of_date_github_permissions(organization, latest.blocks) + if not missing_by_repo: + return + + # Repos a tool call failed against in an earlier iteration have been + # commented on before, so exclude them. + repos_with_prior_failure: set[str] = set() + for iteration in earlier: + repos_with_prior_failure |= repos_with_failed_tool_calls(iteration.blocks) + + missing_by_repo = { + repo: info + for repo, info in missing_by_repo.items() + if repo not in repos_with_prior_failure + } + if not missing_by_repo: + return + + logger.info( + "autofix.on_completion_hook.github_permissions_out_of_date", + extra={ + "run_id": run_id, + "organization_id": organization.id, + "missing_by_repo": { + repo: { + "missing_scopes": info.missing_scopes, + "installation_id": info.installation_id, + } + for repo, info in missing_by_repo.items() + }, + }, + ) + + comment_on_out_of_date_github_permissions(organization, state, missing_by_repo) + @classmethod def find_latest_artifact_for_step(cls, state: SeerRunState, key: str) -> Artifact | None: for block in reversed(state.blocks): diff --git a/src/sentry/seer/autofix/types.py b/src/sentry/seer/autofix/types.py index 31879a9201ec..85a732347857 100644 --- a/src/sentry/seer/autofix/types.py +++ b/src/sentry/seer/autofix/types.py @@ -1,6 +1,24 @@ from __future__ import annotations -from typing import Any, TypedDict +from typing import Annotated, Any, Literal, TypedDict, Union + +from pydantic import BaseModel, Field + + +class GithubAppPermissionsWarning(BaseModel): + """The GitHub App installation backing a touched repo is missing permissions + Seer needs; the user should re-accept them.""" + + warning_type: Literal["github_app_permissions"] = "github_app_permissions" + repo_name: str + installation_id: str + + +# Discriminated on `warning_type`; add new warning models to this union. +AutofixWarning = Annotated[ + Union[GithubAppPermissionsWarning], + Field(discriminator="warning_type"), +] class AutofixPostResponse(TypedDict): diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 66783ab60e88..60eaede675ae 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -47,6 +47,7 @@ NoSeerQuotaException, get_autofix_agent_state, get_autofix_run_state, + get_iterations, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -60,10 +61,14 @@ enqueue_autofix_feedback, peek_queued_autofix_feedback, ) +from sentry.seer.autofix.github_perms import ( + get_out_of_date_github_permissions, +) from sentry.seer.autofix.types import ( AutofixHandoffResponse, AutofixPostResponse, AutofixStateResponse, + GithubAppPermissionsWarning, ) from sentry.seer.autofix.utils import ( AutofixStoppingPoint, @@ -476,6 +481,19 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: run = get_seer_run(state.run_id, group.organization) blocks = [block.dict() for block in state.blocks] + iteration_blocks = [ + block for iteration in get_iterations(state) for block in iteration.blocks + ] + missing_perms = get_out_of_date_github_permissions( + group.organization, iteration_blocks + ) + warnings = [ + GithubAppPermissionsWarning( + repo_name=repo_name, + installation_id=info.installation_id, + ).dict() + for repo_name, info in missing_perms.items() + ] queued_feedback = [ item.feedback.dict() for item in peek_queued_autofix_feedback(state.run_id) ] @@ -500,6 +518,7 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: "organizations:autofix-pr-iteration", group.organization ), "queued_feedback": queued_feedback, + "warnings": warnings, } } ) diff --git a/tests/sentry/seer/autofix/test_github_perms.py b/tests/sentry/seer/autofix/test_github_perms.py new file mode 100644 index 000000000000..ec283b130974 --- /dev/null +++ b/tests/sentry/seer/autofix/test_github_perms.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from collections.abc import Sequence + +from sentry.seer.agent.client_models import ( + MemoryBlock, + Message, + ToolCall, + ToolLink, + ToolResult, +) +from sentry.seer.autofix.github_perms import ( + blocks_have_failed_tool_call, + repos_with_failed_tool_calls, +) + + +def _block( + *, + calls: Sequence[tuple[str, str | None, bool]] = (), +) -> MemoryBlock: + """Build a block from (function, repo_name, is_error) tuples. tool_links and + tool_results are kept index-aligned with the tool calls, mirroring seer.""" + tool_calls: list[ToolCall] = [] + tool_links: list[ToolLink | None] = [] + tool_results: list[ToolResult | None] = [] + for i, (fn, repo, is_error) in enumerate(calls): + call_id = f"call-{i}" + args = json.dumps({"repo_name": repo} if repo is not None else {}) + tool_calls.append(ToolCall(id=call_id, function=fn, args=args)) + tool_links.append(ToolLink(kind=fn, params={"is_error": True}) if is_error else None) + tool_results.append( + ToolResult(tool_call_id=call_id, tool_call_function=fn, content="x") + ) + return MemoryBlock( + id="b", + message=Message(role="assistant", content="", tool_calls=tool_calls or None), + timestamp="2023-07-18T12:00:00Z", + tool_links=tool_links or None, + tool_results=tool_results or None, + ) + + +def test_no_blocks() -> None: + assert repos_with_failed_tool_calls([]) == set() + assert blocks_have_failed_tool_call([]) is False + + +def test_ignores_successful_tool_calls() -> None: + block = _block(calls=[("code_file_edit", "org/repo", False)]) + assert repos_with_failed_tool_calls([block]) == set() + assert blocks_have_failed_tool_call([block]) is False + + +def test_returns_repo_of_failed_tool_call() -> None: + block = _block(calls=[("summarize_failed_ci_logs", "org/repo", True)]) + assert repos_with_failed_tool_calls([block]) == {"org/repo"} + assert blocks_have_failed_tool_call([block]) is True + + +def test_failed_tool_call_without_repo_is_not_attributed() -> None: + block = _block(calls=[("get_issue_details", None, True)]) + assert repos_with_failed_tool_calls([block]) == set() + # It still counts as a failed tool call, just not against a repo. + assert blocks_have_failed_tool_call([block]) is True + + +def test_only_failed_call_repo_is_returned() -> None: + # A success against repo-a and a failure against repo-b in the same block. + block = _block( + calls=[ + ("code_file_edit", "org/repo-a", False), + ("summarize_failed_ci_logs", "org/repo-b", True), + ] + ) + assert repos_with_failed_tool_calls([block]) == {"org/repo-b"} + + +def test_aggregates_across_blocks() -> None: + blocks = [ + _block(calls=[("t", "org/repo-a", True)]), + _block(calls=[("t", "org/repo-b", True)]), + ] + assert repos_with_failed_tool_calls(blocks) == {"org/repo-a", "org/repo-b"} diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 5e84e847788d..68a198ce34e7 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -1,8 +1,21 @@ from __future__ import annotations +import json +from collections.abc import Sequence from unittest.mock import patch +from sentry.integrations.services.integration import RpcIntegration +from sentry.seer.agent.client_models import ( + MemoryBlock, + Message, + SeerRunState, + ToolCall, + ToolLink, + ToolResult, +) +from sentry.seer.autofix.autofix_agent import AutofixStep from sentry.seer.autofix.coding_agent import IntegrationNotFound +from sentry.seer.autofix.github_perms import MissingGithubPermissions from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook from sentry.seer.autofix.utils import CodingAgentProviderType from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration @@ -41,3 +54,132 @@ def test_not_found_clears_automation_handoff(self, mock_trigger) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None + + +def _iteration_block(index: int, *, failed: bool = False, repos: Sequence[str] = ()) -> MemoryBlock: + """An iteration block. When `failed`, holds one errored tool call per repo in + `repos` (each carrying that repo in its args); with no repos, a single errored + tool call not attributable to any repo.""" + tool_calls: list[ToolCall] = [] + tool_links: list[ToolLink | None] = [] + tool_results: list[ToolResult | None] = [] + if failed: + for n, repo in enumerate(list(repos) or [None]): + call_id = f"call-{index}-{n}" + args = json.dumps({"repo_name": repo} if repo else {}) + tool_calls.append(ToolCall(id=call_id, function="tool", args=args)) + tool_links.append(ToolLink(kind="tool", params={"is_error": True})) + tool_results.append( + ToolResult(tool_call_id=call_id, tool_call_function="tool", content="Error") + ) + return MemoryBlock( + id=f"iter-{index}", + message=Message( + role="assistant", + content="", + tool_calls=tool_calls or None, + metadata={ + "step": AutofixStep.PR_ITERATION.value, + "iteration_index": str(index), + }, + ), + timestamp="2023-07-18T12:00:00Z", + tool_links=tool_links or None, + tool_results=tool_results or None, + ) + + +def _perms(integration_id: int) -> MissingGithubPermissions: + return MissingGithubPermissions( + integration=RpcIntegration( + id=integration_id, + provider="github", + external_id=str(integration_id), + name="octocat", + metadata={}, + status=0, + ), + missing_scopes=["contents"], + ) + + +def _state(blocks: list[MemoryBlock]) -> SeerRunState: + return SeerRunState( + run_id=1, + blocks=blocks, + status="completed", + updated_at="2023-07-18T12:00:00Z", + ) + + +@patch("sentry.seer.autofix.on_completion_hook.comment_on_out_of_date_github_permissions") +@patch("sentry.seer.autofix.on_completion_hook.get_out_of_date_github_permissions") +class TestMaybeCommentOnMissingPermissions(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + + def _run(self, state: SeerRunState) -> None: + AutofixOnCompletionHook._maybe_comment_on_missing_permissions( + self.organization, run_id=1, state=state + ) + + def test_no_iterations(self, mock_get_perms, mock_comment) -> None: + self._run(_state([])) + + mock_get_perms.assert_not_called() + mock_comment.assert_not_called() + + def test_latest_iteration_did_not_fail(self, mock_get_perms, mock_comment) -> None: + self._run(_state([_iteration_block(0, failed=False, repos=["repo-a"])])) + + mock_get_perms.assert_not_called() + mock_comment.assert_not_called() + + def test_no_missing_permissions(self, mock_get_perms, mock_comment) -> None: + mock_get_perms.return_value = {} + + self._run(_state([_iteration_block(0, failed=True, repos=["repo-a"])])) + + mock_get_perms.assert_called_once() + mock_comment.assert_not_called() + + def test_comments_on_missing_permissions(self, mock_get_perms, mock_comment) -> None: + perms = _perms(42) + mock_get_perms.return_value = {"repo-a": perms} + + state = _state([_iteration_block(0, failed=True, repos=["repo-a"])]) + self._run(state) + + mock_comment.assert_called_once_with(self.organization, state, {"repo-a": perms}) + + def test_skips_repo_with_prior_failing_iteration(self, mock_get_perms, mock_comment) -> None: + perms = _perms(42) + mock_get_perms.return_value = {"repo-a": perms} + + # repo-a already failed in an earlier iteration -> excluded from the latest. + state = _state( + [ + _iteration_block(0, failed=True, repos=["repo-a"]), + _iteration_block(1, failed=True, repos=["repo-a"]), + ] + ) + self._run(state) + + mock_comment.assert_not_called() + + def test_comments_only_on_newly_failing_repo(self, mock_get_perms, mock_comment) -> None: + perms_a = _perms(1) + perms_b = _perms(2) + mock_get_perms.return_value = {"repo-a": perms_a, "repo-b": perms_b} + + # repo-a failed before; repo-b is failing for the first time now. + state = _state( + [ + _iteration_block(0, failed=True, repos=["repo-a"]), + _iteration_block(1, failed=True, repos=["repo-a", "repo-b"]), + ] + ) + self._run(state) + + mock_comment.assert_called_once_with(self.organization, state, {"repo-b": perms_b}) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 71191017317a..d7aa5f3d1a96 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -1,10 +1,12 @@ import uuid from unittest.mock import Mock, patch +from sentry.integrations.services.integration import RpcIntegration from sentry.issues.action_log.types import TriggerAutofixAction from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState from sentry.seer.autofix.autofix_agent import AutofixStep, NoSeerQuotaException from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.github_perms import MissingGithubPermissions from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SeerPermissionError from sentry.testutils.cases import APITestCase, SnubaTestCase @@ -80,6 +82,65 @@ def test_get_handles_block_with_null_metadata(self, mock_get_explorer_state): assert response.status_code == 200, response.data assert response.data["autofix"]["blocks"][0]["message"]["metadata"] is None + @patch("sentry.seer.endpoints.group_ai_autofix.get_out_of_date_github_permissions") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + def test_get_no_warnings_when_no_missing_permissions( + self, mock_get_explorer_state, mock_get_perms + ): + group = self.create_group() + mock_get_explorer_state.return_value = SeerRunState( + run_id=888, + blocks=[], + status="completed", + updated_at="2023-07-18T12:00:00Z", + ) + mock_get_perms.return_value = {} + + self.login_as(user=self.user) + response = self.client.get(self._get_url(group.id), format="json") + + assert response.status_code == 200, response.data + assert response.data["autofix"]["warnings"] == [] + mock_get_perms.assert_called_once() + + @patch("sentry.seer.endpoints.group_ai_autofix.get_out_of_date_github_permissions") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + def test_get_returns_github_permission_warnings( + self, mock_get_explorer_state, mock_get_perms + ): + group = self.create_group() + mock_get_explorer_state.return_value = SeerRunState( + run_id=888, + blocks=[], + status="completed", + updated_at="2023-07-18T12:00:00Z", + ) + mock_get_perms.return_value = { + "getsentry/sentry": MissingGithubPermissions( + integration=RpcIntegration( + id=42, + provider="github", + external_id="9999", + name="octocat", + metadata={}, + status=0, + ), + missing_scopes=["contents"], + ) + } + + self.login_as(user=self.user) + response = self.client.get(self._get_url(group.id), format="json") + + assert response.status_code == 200, response.data + assert response.data["autofix"]["warnings"] == [ + { + "warning_type": "github_app_permissions", + "repo_name": "getsentry/sentry", + "installation_id": "9999", + } + ] + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_triggers_autofix_agent(self, mock_trigger_explorer): group = self.create_group() From 37af874a13bad41eb8a1e94464c7ce6b238b58ea Mon Sep 17 00:00:00 2001 From: Joseph Sawaya Date: Mon, 6 Jul 2026 09:05:06 -0400 Subject: [PATCH 2/9] fix --- src/sentry/seer/autofix/github_perms.py | 2 +- src/sentry/seer/endpoints/group_ai_autofix.py | 4 +--- tests/sentry/seer/autofix/test_github_perms.py | 6 ++---- tests/sentry/seer/autofix/test_on_completion_hook.py | 2 +- tests/sentry/seer/endpoints/test_group_ai_autofix.py | 4 +--- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/sentry/seer/autofix/github_perms.py b/src/sentry/seer/autofix/github_perms.py index e61e5fb03ae9..47ceef2d6059 100644 --- a/src/sentry/seer/autofix/github_perms.py +++ b/src/sentry/seer/autofix/github_perms.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass @@ -11,6 +10,7 @@ from sentry.integrations.utils.github_permissions import get_missing_github_app_permissions from sentry.models.organization import Organization from sentry.models.repository import Repository +from sentry.utils import json if TYPE_CHECKING: from sentry.seer.agent.client_models import MemoryBlock, SeerRunState, ToolCall diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 60eaede675ae..c2686d020347 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -484,9 +484,7 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: iteration_blocks = [ block for iteration in get_iterations(state) for block in iteration.blocks ] - missing_perms = get_out_of_date_github_permissions( - group.organization, iteration_blocks - ) + missing_perms = get_out_of_date_github_permissions(group.organization, iteration_blocks) warnings = [ GithubAppPermissionsWarning( repo_name=repo_name, diff --git a/tests/sentry/seer/autofix/test_github_perms.py b/tests/sentry/seer/autofix/test_github_perms.py index ec283b130974..0aeaf4e97046 100644 --- a/tests/sentry/seer/autofix/test_github_perms.py +++ b/tests/sentry/seer/autofix/test_github_perms.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from collections.abc import Sequence from sentry.seer.agent.client_models import ( @@ -14,6 +13,7 @@ blocks_have_failed_tool_call, repos_with_failed_tool_calls, ) +from sentry.utils import json def _block( @@ -30,9 +30,7 @@ def _block( args = json.dumps({"repo_name": repo} if repo is not None else {}) tool_calls.append(ToolCall(id=call_id, function=fn, args=args)) tool_links.append(ToolLink(kind=fn, params={"is_error": True}) if is_error else None) - tool_results.append( - ToolResult(tool_call_id=call_id, tool_call_function=fn, content="x") - ) + tool_results.append(ToolResult(tool_call_id=call_id, tool_call_function=fn, content="x")) return MemoryBlock( id="b", message=Message(role="assistant", content="", tool_calls=tool_calls or None), diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 68a198ce34e7..68417da04187 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from collections.abc import Sequence from unittest.mock import patch @@ -20,6 +19,7 @@ from sentry.seer.autofix.utils import CodingAgentProviderType from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration from sentry.testutils.cases import TestCase +from sentry.utils import json class TestTriggerCodingAgentHandoff(TestCase): diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index d7aa5f3d1a96..a205def53ce8 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -105,9 +105,7 @@ def test_get_no_warnings_when_no_missing_permissions( @patch("sentry.seer.endpoints.group_ai_autofix.get_out_of_date_github_permissions") @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") - def test_get_returns_github_permission_warnings( - self, mock_get_explorer_state, mock_get_perms - ): + def test_get_returns_github_permission_warnings(self, mock_get_explorer_state, mock_get_perms): group = self.create_group() mock_get_explorer_state.return_value = SeerRunState( run_id=888, From 8ed4f7e1bcb56b40eb73a33ffdf9ecce4aced8f9 Mon Sep 17 00:00:00 2001 From: Joseph Sawaya Date: Mon, 29 Jun 2026 11:10:34 -0400 Subject: [PATCH 3/9] github app permission nudge FE --- .../autofixGithubAppPermissionsModal.tsx | 27 +++- .../events/autofix/useExplorerAutofix.tsx | 9 +- .../events/autofix/v3/autofixCards.spec.tsx | 1 + .../components/events/autofix/v3/drawer.tsx | 137 +++++++++++++++++- .../events/autofix/v3/nextStep.spec.tsx | 1 + .../v3/prIterationFeedbackForm.spec.tsx | 1 + .../autofix/v3/useAutoTriggerAutofix.spec.tsx | 1 + .../autofix/v3/useResetAutofixStep.spec.tsx | 1 + static/app/utils/integrationUtil.tsx | 7 + 9 files changed, 175 insertions(+), 10 deletions(-) diff --git a/static/app/components/events/autofix/autofixGithubAppPermissionsModal.tsx b/static/app/components/events/autofix/autofixGithubAppPermissionsModal.tsx index 7d6371f5d3f1..542c4e5cc982 100644 --- a/static/app/components/events/autofix/autofixGithubAppPermissionsModal.tsx +++ b/static/app/components/events/autofix/autofixGithubAppPermissionsModal.tsx @@ -9,7 +9,9 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {t, tct} from 'sentry/locale'; interface AutofixGithubAppPermissionsModalProps extends ModalRenderProps { + description?: React.ReactNode; installationUrl?: string; + onUpdatePermissionsClick?: () => void; } const DEFAULT_INSTALLATIONS_URL = 'https://github.com/settings/installations/'; @@ -20,6 +22,8 @@ export function AutofixGithubAppPermissionsModal({ Footer, closeModal, installationUrl, + description, + onUpdatePermissionsClick, }: AutofixGithubAppPermissionsModalProps) { const settingsUrl = installationUrl ?? DEFAULT_INSTALLATIONS_URL; @@ -30,18 +34,27 @@ export function AutofixGithubAppPermissionsModal({ - {tct( - 'The Sentry GitHub App does not have sufficient permissions to launch a coding agent. Please update your [link:GitHub App installation settings] to grant the required permissions.', - { - link: , - } - )} + {description ?? + tct( + 'The Sentry GitHub App does not have sufficient permissions to launch a coding agent. Please update your [link:GitHub App installation settings] to grant the required permissions.', + { + link: , + } + )}