22
33import ast
44import os
5+ from collections import Counter
56from collections .abc import Generator
67from datetime import datetime , timezone
78from typing import Any
164165 "raw urlopen bypasses ViewerContext propagation"
165166)
166167
168+ S030_msg = (
169+ "S030 Do not add new callers of the legacy Seer LLM generate API; "
170+ "add a named Seer one-shot so prompts and model selection stay in Seer"
171+ )
172+ S030_stale_msg = (
173+ "S030 stale legacy Seer LLM generate allowlist entry for {} in {}; "
174+ "remove it from S030_allowlist"
175+ )
176+ _S030_LEGACY_MODULE = "sentry.seer.signed_seer_api"
177+ _S030_LEGACY_NAMES = frozenset (("LlmGenerateRequest" , "make_llm_generate_request" ))
178+ _S030_LEGACY_ROUTE = "/v1/llm/generate"
179+ _S030_MODULE_SCOPE = "<module>"
180+
181+ # Existing callers are pinned to their exact function and occurrence count. This is a
182+ # shrink-only migration baseline: remove entries as callers move to named Seer one-shots.
183+ S030_allowlist : dict [str , dict [tuple [str , str ], int ]] = {
184+ "src/sentry/integrations/utils/external_issues.py" : {
185+ (_S030_MODULE_SCOPE , "import LlmGenerateRequest" ): 1 ,
186+ (_S030_MODULE_SCOPE , "import make_llm_generate_request" ): 1 ,
187+ ("_make_generate_external_issue_details_request" , "call LlmGenerateRequest" ): 1 ,
188+ ("_make_generate_external_issue_details_request" , "call make_llm_generate_request" ): 1 ,
189+ },
190+ "src/sentry/seer/endpoints/issue_view_title_generate.py" : {
191+ (_S030_MODULE_SCOPE , "import LlmGenerateRequest" ): 1 ,
192+ (_S030_MODULE_SCOPE , "import make_llm_generate_request" ): 1 ,
193+ ("generate_title_from_query" , "call LlmGenerateRequest" ): 1 ,
194+ ("generate_title_from_query" , "call make_llm_generate_request" ): 1 ,
195+ },
196+ "src/sentry/tasks/seer/autofix_issue_data.py" : {
197+ (_S030_MODULE_SCOPE , "import LlmGenerateRequest" ): 1 ,
198+ (_S030_MODULE_SCOPE , "import make_llm_generate_request" ): 1 ,
199+ ("_judge_issue" , "call LlmGenerateRequest" ): 1 ,
200+ ("_judge_issue" , "call make_llm_generate_request" ): 1 ,
201+ },
202+ "src/sentry/uptime/seer_assertions.py" : {
203+ (_S030_MODULE_SCOPE , "import LlmGenerateRequest" ): 1 ,
204+ (_S030_MODULE_SCOPE , "import make_llm_generate_request" ): 1 ,
205+ ("generate_assertion_suggestions" , "call LlmGenerateRequest" ): 1 ,
206+ ("generate_assertion_suggestions" , "call make_llm_generate_request" ): 1 ,
207+ },
208+ }
209+
167210
168211# Rules whose diagnostics are fatal. Empty ships every rule off; adding one
169212# gates every endpoint of that shape at once, with no baseline to maintain.
@@ -715,6 +758,10 @@ def __init__(
715758 # S029: pools created from SEER_*_URL must not make raw requests.
716759 self ._s029_seer_pool_names : set [str ] = set ()
717760 self ._s029_urlopen_calls : list [tuple [int , int , str ]] = []
761+ # S030: legacy generic Seer LLM API imports, calls, and route bypasses.
762+ self ._s030_legacy_names : dict [str , str ] = {}
763+ self ._s030_module_aliases : set [str ] = set ()
764+ self ._s030_uses : list [tuple [int , int , str , str ]] = []
718765 # Module-level classes, indexed as encountered. A base is always defined
719766 # before its subclass at module level, so in-order indexing suffices.
720767 self ._module_classes : dict [str , ast .ClassDef ] = {}
@@ -727,6 +774,13 @@ def __init__(
727774 self ._function_stack : list [str ] = []
728775
729776 def visit_ImportFrom (self , node : ast .ImportFrom ) -> None :
777+ if node .module == _S030_LEGACY_MODULE and not node .level :
778+ for alias in node .names :
779+ if alias .name in _S030_LEGACY_NAMES :
780+ local_name = alias .asname or alias .name
781+ self ._s030_legacy_names [local_name ] = alias .name
782+ self ._s030_record (node , f"import { alias .name } " )
783+
730784 if node .module and not node .level :
731785 if node .module .split ("." )[0 ] in S003_modules :
732786 self .errors .append ((node .lineno , node .col_offset , S003_msg ))
@@ -776,6 +830,10 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
776830
777831 def visit_Import (self , node : ast .Import ) -> None :
778832 for alias in node .names :
833+ if alias .name == _S030_LEGACY_MODULE :
834+ if alias .asname :
835+ self ._s030_module_aliases .add (alias .asname )
836+ self ._s030_record (node , f"import { alias .name } " )
779837 if alias .name .split ("." )[0 ] in S003_modules :
780838 self .errors .append ((node .lineno , node .col_offset , S003_msg ))
781839 elif (
@@ -818,6 +876,8 @@ def visit_Name(self, node: ast.Name) -> None:
818876 def visit_Constant (self , node : ast .Constant ) -> None :
819877 if isinstance (node .value , str ) and node .value == S018_fqn :
820878 self .errors .append ((node .lineno , node .col_offset , S018_msg ))
879+ elif isinstance (node .value , str ) and node .value == _S030_LEGACY_ROUTE :
880+ self ._s030_record (node , f"use { _S030_LEGACY_ROUTE } " )
821881
822882 self .generic_visit (node )
823883
@@ -1032,6 +1092,22 @@ def _s024_visit_call(self, node: ast.Call) -> None:
10321092 if self ._s024_parses is None :
10331093 self ._s024_parses = (node .lineno , node .col_offset )
10341094
1095+ def _s030_record (self , node : ast .stmt | ast .expr , kind : str ) -> None :
1096+ function = self ._function_stack [- 1 ] if self ._function_stack else _S030_MODULE_SCOPE
1097+ self ._s030_uses .append ((node .lineno , node .col_offset , function , kind ))
1098+
1099+ def _s030_call_name (self , node : ast .expr ) -> str | None :
1100+ if isinstance (node , ast .Name ):
1101+ return self ._s030_legacy_names .get (node .id )
1102+
1103+ qualified = _name_of (node )
1104+ for legacy_name in _S030_LEGACY_NAMES :
1105+ if qualified == f"{ _S030_LEGACY_MODULE } .{ legacy_name } " :
1106+ return legacy_name
1107+ if any (qualified == f"{ alias } .{ legacy_name } " for alias in self ._s030_module_aliases ):
1108+ return legacy_name
1109+ return None
1110+
10351111 def visit_Subscript (self , node : ast .Subscript ) -> None :
10361112 # Load only: `request.data["title"] = ...` writes a value, it does not
10371113 # read a parameter the client sent.
@@ -1120,6 +1196,8 @@ def _exit_input(self) -> None:
11201196
11211197 def visit_Call (self , node : ast .Call ) -> None :
11221198 self ._s024_visit_call (node )
1199+ if legacy_name := self ._s030_call_name (node .func ):
1200+ self ._s030_record (node , f"call { legacy_name } " )
11231201 if isinstance (node .func , ast .Attribute ) and node .func .attr == "urlopen" :
11241202 self ._s029_urlopen_calls .append (
11251203 (node .lineno , node .col_offset , _name_of (node .func .value ))
@@ -1201,6 +1279,7 @@ def run(self) -> Generator[tuple[int, int, str, type[Any]]]:
12011279
12021280 yield from self ._s024 (visitor )
12031281 yield from self ._s029 (visitor )
1282+ yield from self ._s030 (visitor )
12041283
12051284 for e in visitor .errors :
12061285 yield (* e , type (self ))
@@ -1221,3 +1300,21 @@ def _s029(self, visitor: SentryVisitor) -> Generator[tuple[int, int, str, type[A
12211300 for line , col , receiver in visitor ._s029_urlopen_calls :
12221301 if receiver in visitor ._s029_seer_pool_names :
12231302 yield (line , col , S029_msg , type (self ))
1303+
1304+ def _s030 (self , visitor : SentryVisitor ) -> Generator [tuple [int , int , str , type [Any ]]]:
1305+ rel = _repo_relative (self .filename )
1306+ if not rel .startswith ("src/sentry/" ) or rel == "src/sentry/seer/signed_seer_api.py" :
1307+ return
1308+
1309+ allowed = S030_allowlist .get (rel , {})
1310+ seen : Counter [tuple [str , str ]] = Counter ()
1311+ for line , col , function , kind in visitor ._s030_uses :
1312+ key = (function , kind )
1313+ seen [key ] += 1
1314+ if seen [key ] > allowed .get (key , 0 ):
1315+ yield (line , col , S030_msg , type (self ))
1316+
1317+ for key , expected_count in allowed .items ():
1318+ if seen [key ] < expected_count :
1319+ function , kind = key
1320+ yield (1 , 0 , S030_stale_msg .format (kind , function ), type (self ))
0 commit comments