Skip to content

Commit 2fcccf6

Browse files
authored
ref(coding-agents): Extract coding agent triggering logic to dedicated file (#102316)
Move coding agent triggering logic to dedicated file in preparation to be used for calling from seer rpc for cursor agent automation trigger
1 parent 2706324 commit 2fcccf6

File tree

3 files changed

+427
-431
lines changed

3 files changed

+427
-431
lines changed
Lines changed: 9 additions & 328 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
from __future__ import annotations
22

33
import logging
4-
import secrets
5-
import string
64

7-
import orjson
8-
from django.conf import settings
9-
from requests import HTTPError
10-
from rest_framework import serializers, status
11-
from rest_framework.exceptions import APIException, NotFound, ValidationError
5+
from rest_framework import serializers
6+
from rest_framework.exceptions import ValidationError
127
from rest_framework.request import Request
138
from rest_framework.response import Response
149

@@ -18,97 +13,15 @@
1813
from sentry.api.base import region_silo_endpoint
1914
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationEventPermission
2015
from sentry.constants import ObjectStatus
21-
from sentry.integrations.coding_agent.integration import CodingAgentIntegration
22-
from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest
2316
from sentry.integrations.coding_agent.utils import get_coding_agent_providers
2417
from sentry.integrations.services.integration import integration_service
2518
from sentry.models.organization import Organization
26-
from sentry.net.http import connection_from_url
27-
from sentry.seer.autofix.utils import (
28-
AutofixState,
29-
AutofixTriggerSource,
30-
CodingAgentState,
31-
get_autofix_state,
32-
get_coding_agent_prompt,
33-
)
34-
from sentry.seer.models import SeerApiError
35-
from sentry.seer.signed_seer_api import make_signed_seer_api_request
36-
from sentry.shared_integrations.exceptions import ApiError
19+
from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run
20+
from sentry.seer.autofix.utils import AutofixTriggerSource
3721

3822
logger = logging.getLogger(__name__)
3923

4024

41-
# Follows the GitHub branch name rules:
42-
# https://docs.github.com/en/get-started/using-git/dealing-with-special-characters-in-branch-and-tag-names#naming-branches-and-tags
43-
# As our coding agent integration only supports launching on GitHub right now.
44-
VALID_BRANCH_NAME_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_/"
45-
46-
47-
def sanitize_branch_name(branch_name: str) -> str:
48-
"""
49-
Sanitize a branch name by taking the first 3 words, converting to lowercase,
50-
removing/replacing special characters, and adding 6 unique random characters.
51-
52-
Args:
53-
branch_name: The raw branch name to sanitize
54-
55-
Returns:
56-
A sanitized branch name safe for use as a Git branch
57-
"""
58-
# Take only the first 3 words
59-
words = branch_name.strip().split()[:3]
60-
truncated_name = " ".join(words)
61-
62-
# Although underscores are allowed, we standardize to kebab case.
63-
kebab_case = truncated_name.replace(" ", "-").replace("_", "-").lower()
64-
sanitized = "".join(c for c in kebab_case if c in VALID_BRANCH_NAME_CHARS)
65-
sanitized = sanitized.rstrip("/")
66-
67-
# Generate 6 unique random characters (alphanumeric)
68-
# This is to avoid potential branch name conflicts.
69-
random_suffix = "".join(
70-
secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6)
71-
)
72-
73-
# Combine sanitized name with random suffix
74-
return f"{sanitized}-{random_suffix}" if sanitized else f"branch-{random_suffix}"
75-
76-
77-
def store_coding_agent_states_to_seer(
78-
run_id: int, coding_agent_states: list[CodingAgentState]
79-
) -> None:
80-
"""Store multiple coding agent states via Seer batch API."""
81-
if not coding_agent_states:
82-
return
83-
path = "/v1/automation/autofix/coding-agent/state/set"
84-
body = orjson.dumps(
85-
{
86-
"run_id": run_id,
87-
"coding_agent_states": [state.dict() for state in coding_agent_states],
88-
}
89-
)
90-
91-
connection_pool = connection_from_url(settings.SEER_AUTOFIX_URL)
92-
response = make_signed_seer_api_request(
93-
connection_pool,
94-
path,
95-
body=body,
96-
timeout=30,
97-
)
98-
99-
if response.status >= 400:
100-
raise SeerApiError(response.data.decode("utf-8"), response.status)
101-
102-
logger.info(
103-
"coding_agent.states_stored_to_seer",
104-
extra={
105-
"run_id": run_id,
106-
"status_code": response.status,
107-
"num_states": len(coding_agent_states),
108-
},
109-
)
110-
111-
11225
class OrganizationCodingAgentLaunchSerializer(serializers.Serializer[dict[str, object]]):
11326
integration_id = serializers.IntegerField(required=True)
11427
run_id = serializers.IntegerField(required=True, min_value=1)
@@ -157,11 +70,6 @@ def get(self, request: Request, organization: Organization) -> Response:
15770

15871
def post(self, request: Request, organization: Organization) -> Response:
15972
"""Launch a coding agent."""
160-
if not features.has("organizations:seer-coding-agent-integrations", organization):
161-
return self.respond(
162-
{"detail": "Feature not available"}, status=status.HTTP_404_NOT_FOUND
163-
)
164-
16573
serializer = OrganizationCodingAgentLaunchSerializer(data=request.data)
16674
if not serializer.is_valid():
16775
raise ValidationError(serializer.errors)
@@ -172,238 +80,11 @@ def post(self, request: Request, organization: Organization) -> Response:
17280
integration_id = validated["integration_id"]
17381
trigger_source = validated["trigger_source"]
17482

175-
integration, installation = self._validate_and_get_integration(
176-
request, organization, integration_id
177-
)
178-
179-
autofix_state = self._get_autofix_state(run_id, organization)
180-
if autofix_state is None:
181-
return self.respond(
182-
{"detail": "Autofix state not found"}, status=status.HTTP_400_BAD_REQUEST
183-
)
184-
185-
logger.info(
186-
"coding_agent.launch_request",
187-
extra={
188-
"organization_id": organization.id,
189-
"integration_id": integration.id,
190-
"run_id": run_id,
191-
},
192-
)
193-
194-
results = self._launch_agents_for_repos(
195-
installation, autofix_state, run_id, organization, trigger_source
196-
)
197-
198-
if not results:
199-
return self.respond(
200-
{"detail": "No agents were launched"},
201-
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
202-
)
203-
204-
logger.info(
205-
"coding_agent.launch_success",
206-
extra={
207-
"organization_id": organization.id,
208-
"integration_id": integration.id,
209-
"provider": integration.provider,
210-
"run_id": run_id,
211-
"repos_processed": len(results),
212-
},
213-
)
214-
215-
return self.respond(
216-
{
217-
"success": True,
218-
}
219-
)
220-
221-
def _validate_and_get_integration(self, request: Request, organization, integration_id: int):
222-
"""Validate request and get the coding agent integration."""
223-
integration_id_int = integration_id
224-
225-
org_integration = integration_service.get_organization_integration(
83+
launch_coding_agents_for_run(
22684
organization_id=organization.id,
227-
integration_id=integration_id_int,
228-
)
229-
230-
if not org_integration or org_integration.status != ObjectStatus.ACTIVE:
231-
raise NotFound("Integration not found")
232-
233-
integration = integration_service.get_integration(
234-
organization_integration_id=org_integration.id,
235-
status=ObjectStatus.ACTIVE,
236-
)
237-
238-
if not integration:
239-
raise NotFound("Integration not found")
240-
241-
# Verify it's a coding agent integration
242-
if integration.provider not in get_coding_agent_providers():
243-
raise ValidationError("Not a coding agent integration")
244-
245-
# Get the installation
246-
installation = integration.get_installation(organization.id)
247-
if not isinstance(installation, CodingAgentIntegration):
248-
raise ValidationError("Invalid coding agent integration")
249-
250-
return integration, installation
251-
252-
def _get_autofix_state(self, run_id: int, organization: Organization) -> AutofixState | None:
253-
"""Extract and validate run_id and get autofix state."""
254-
autofix_state = get_autofix_state(run_id=run_id, organization_id=organization.id)
255-
256-
if not autofix_state:
257-
logger.warning(
258-
"coding_agent.post.autofix_state_not_found",
259-
extra={
260-
"organization_id": organization.id,
261-
"run_id": run_id,
262-
},
263-
)
264-
return None
265-
266-
return autofix_state
267-
268-
def _extract_repos_from_root_cause(self, autofix_state: AutofixState) -> list[str]:
269-
"""Extract repository names from autofix state root cause."""
270-
root_cause_step = next(
271-
(step for step in autofix_state.steps if step["key"] == "root_cause_analysis"), None
272-
)
273-
274-
if not root_cause_step or not root_cause_step["causes"]:
275-
return []
276-
277-
cause = root_cause_step["causes"][0]
278-
279-
if "relevant_repos" not in cause:
280-
return []
281-
282-
return list(set(cause["relevant_repos"])) or []
283-
284-
def _extract_repos_from_solution(self, autofix_state: AutofixState) -> list[str]:
285-
"""Extract repository names from autofix state solution."""
286-
repos = set()
287-
solution_step = next(
288-
(step for step in autofix_state.steps if step["key"] == "solution"), None
85+
integration_id=integration_id,
86+
run_id=run_id,
87+
trigger_source=trigger_source,
28988
)
29089

291-
if not solution_step:
292-
return []
293-
294-
for solution_item in solution_step["solution"]:
295-
if (
296-
solution_item["relevant_code_file"]
297-
and "repo_name" in solution_item["relevant_code_file"]
298-
and solution_item["relevant_code_file"]["repo_name"]
299-
):
300-
repos.add(solution_item["relevant_code_file"]["repo_name"])
301-
302-
return list(repos)
303-
304-
def _launch_agents_for_repos(
305-
self,
306-
installation: CodingAgentIntegration,
307-
autofix_state: AutofixState,
308-
run_id: int,
309-
organization,
310-
trigger_source: AutofixTriggerSource,
311-
) -> list[dict]:
312-
"""Launch coding agents for all repositories in the solution."""
313-
314-
repos = set(
315-
self._extract_repos_from_root_cause(autofix_state)
316-
if trigger_source == AutofixTriggerSource.ROOT_CAUSE
317-
else self._extract_repos_from_solution(autofix_state)
318-
)
319-
320-
autofix_state_repos = {f"{repo.owner}/{repo.name}" for repo in autofix_state.request.repos}
321-
322-
# Repos that were in the repos but not in the autofix state
323-
repos_not_found = repos - autofix_state_repos
324-
logger.warning(
325-
"coding_agent.post.repos_not_found",
326-
extra={
327-
"organization_id": organization.id,
328-
"run_id": run_id,
329-
"repos_not_found": repos_not_found,
330-
},
331-
)
332-
333-
validated_repos = repos - repos_not_found
334-
335-
repos_to_launch = validated_repos or autofix_state_repos
336-
337-
if not repos_to_launch:
338-
raise NotFound("No repos to run agents")
339-
340-
prompt = get_coding_agent_prompt(run_id, trigger_source)
341-
342-
if not prompt:
343-
raise APIException("No prompt to send to agents.")
344-
345-
results = []
346-
states_to_store: list[CodingAgentState] = []
347-
348-
for repo_name in repos_to_launch:
349-
repo = next(
350-
(
351-
repo
352-
for repo in autofix_state.request.repos
353-
if f"{repo.owner}/{repo.name}" == repo_name
354-
),
355-
None,
356-
)
357-
if not repo:
358-
logger.error(
359-
"coding_agent.repo_not_found",
360-
extra={
361-
"organization_id": organization.id,
362-
"run_id": run_id,
363-
"repo_name": repo_name,
364-
},
365-
)
366-
# Continue with other repos instead of failing entirely
367-
continue
368-
369-
launch_request = CodingAgentLaunchRequest(
370-
prompt=prompt,
371-
repository=repo,
372-
branch_name=sanitize_branch_name(autofix_state.request.issue["title"]),
373-
)
374-
375-
try:
376-
coding_agent_state = installation.launch(launch_request)
377-
except (HTTPError, ApiError):
378-
logger.exception(
379-
"coding_agent.repo_launch_error",
380-
extra={
381-
"organization_id": organization.id,
382-
"run_id": run_id,
383-
"repo_name": repo_name,
384-
},
385-
)
386-
continue
387-
388-
states_to_store.append(coding_agent_state)
389-
390-
results.append(
391-
{
392-
"repo_name": repo_name,
393-
"coding_agent_state": coding_agent_state,
394-
}
395-
)
396-
397-
try:
398-
store_coding_agent_states_to_seer(run_id=run_id, coding_agent_states=states_to_store)
399-
except SeerApiError:
400-
logger.exception(
401-
"coding_agent.seer_storage_error",
402-
extra={
403-
"organization_id": organization.id,
404-
"run_id": run_id,
405-
"repos": list(repos_to_launch),
406-
},
407-
)
408-
409-
return results
90+
return self.respond({"success": True})

0 commit comments

Comments
 (0)