Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Cloud Build source upload ignore
# gcloud builds submit uses this to exclude files from the source tarball
.devenv/
.git/
.venv/
.webpack_cache/
node_modules/
fixtures/
tests/
*.pyc
__pycache__/
.coverage*
.DS_Store
72 changes: 10 additions & 62 deletions .github/workflows/build-kencove.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ name: Build Kencove Sentry Image
on:
push:
branches:
- master
- '26.1.0-*'
workflow_dispatch:
inputs:
tag:
description: 'Image tag (e.g., v26.1.0-gitlab)'
description: 'Image tag override (default: commit SHA)'
required: false
default: ''

Expand All @@ -27,59 +27,15 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: '.node-version'

- uses: pnpm/action-setup@v4

- uses: astral-sh/setup-uv@v4
with:
version: '0.8.2'

- name: Setup Python venv
run: |
uv venv
source .venv/bin/activate
echo "PATH=$PWD/.venv/bin:$PATH" >> $GITHUB_ENV

- name: Cache webpack
uses: actions/cache@v4
with:
path: .webpack_cache
key: webpack-${{ hashFiles('rspack.config.ts') }}

- name: Cache node_modules
uses: actions/cache@v4
id: node-cache
with:
path: node_modules
key: node-modules-${{ hashFiles('pnpm-lock.yaml') }}

- name: Install Node dependencies
if: steps.node-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile

- name: Build frontend
run: |
python3 -m tools.fast_editable --path .
python3 -m sentry.build.main
env:
WEBPACK_CACHE_PATH: .webpack_cache
NODE_OPTIONS: '--max-old-space-size=4096'

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
service_account: 'github-actions@kencove-prod.iam.gserviceaccount.com'
workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-wlif/providers/github-oidc'
service_account: 'github-actions-seer@kencove-prod.iam.gserviceaccount.com'

- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2

- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

- name: Determine image tag
id: tag
run: |
Expand All @@ -89,19 +45,11 @@ jobs:
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
fi

- name: Build and push Docker image
- name: Submit Cloud Build
run: |
docker build \
-t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} \
-t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest \
-f self-hosted/Dockerfile \
--build-arg SOURCE_COMMIT=${{ github.sha }} \
gcloud builds submit \
--config=cloudbuild.yaml \
--substitutions=COMMIT_SHA=${{ github.sha }},_TAG=${{ steps.tag.outputs.tag }} \
--project=${{ env.PROJECT_ID }} \
--async \
.

docker push ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest

- name: Output image info
run: |
echo "## Build Complete" >> $GITHUB_STEP_SUMMARY
echo "Image: \`${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
34 changes: 32 additions & 2 deletions src/sentry/integrations/cursor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,36 @@
CursorApiKeyMetadata,
)
from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState, CodingAgentStatus
from sentry.seer.models import SeerRepoDefinition

logger = logging.getLogger(__name__)

GITHUB_PROVIDERS = {"github", "integrations:github", "integrations:github_enterprise"}
GITLAB_PROVIDERS = {"integrations:gitlab"}


def _build_repo_url(repo: SeerRepoDefinition) -> str:
"""Build the repository URL based on the provider type.

GitHub: https://github.com/owner/name
GitLab: https://{instance}/owner/name (instance parsed from external_id "host:project_id")
Fallback: GitHub URL for backwards compatibility.
"""
provider = repo.provider
if provider in GITLAB_PROVIDERS:
instance = "gitlab.com"
if repo.external_id and ":" in repo.external_id:
instance = repo.external_id.split(":", 1)[0]
return f"https://{instance}/{repo.owner}/{repo.name}"

# GitHub and fallback
return f"https://github.com/{repo.owner}/{repo.name}"


def _is_github_provider(provider: str) -> bool:
"""Return True if the provider is a GitHub variant."""
return provider in GITHUB_PROVIDERS or provider not in GITLAB_PROVIDERS


class CursorAgentClient(CodingAgentClient):
integration_name = "cursor"
Expand Down Expand Up @@ -51,19 +78,22 @@ def get_api_key_metadata(self) -> CursorApiKeyMetadata:

def launch(self, webhook_url: str, request: CodingAgentLaunchRequest) -> CodingAgentState:
"""Launch coding agent with webhook callback."""
repo_url = _build_repo_url(request.repository)
is_github = _is_github_provider(request.repository.provider)

payload = CursorAgentLaunchRequestBody(
prompt=CursorAgentLaunchRequestPrompt(
text=request.prompt,
),
source=CursorAgentSource(
repository=f"https://github.com/{request.repository.owner}/{request.repository.name}",
repository=repo_url,
ref=request.repository.branch_name,
),
webhook=CursorAgentLaunchRequestWebhook(url=webhook_url, secret=self.webhook_secret),
target=CursorAgentLaunchRequestTarget(
autoCreatePr=request.auto_create_pr,
branchName=request.branch_name,
openAsCursorGithubApp=True,
openAsCursorGithubApp=is_github,
),
)

Expand Down
25 changes: 18 additions & 7 deletions src/sentry/integrations/cursor/webhooks/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,19 +198,30 @@ def _handle_status_change(self, payload: dict[str, Any]) -> None:
repo_url = f"https://{repo_url}"

parsed = urlparse(repo_url)
if parsed.netloc != "github.com":
netloc = parsed.netloc

if netloc == "github.com":
repo_provider = "github"
elif "gitlab" in netloc:
repo_provider = "integrations:gitlab"
else:
logger.error(
"cursor_webhook.not_github_repo",
extra={"agent_id": agent_id, "repo": repo_url},
"cursor_webhook.unsupported_repo_host",
extra={"agent_id": agent_id, "repo": repo_url, "netloc": netloc},
)
return

repo_provider = "github"
repo_full_name = parsed.path.lstrip("/")

# If the repo isn't in the owner/repo format we can't work with it
# Allow dots in the repository name segment (owner.repo is common)
if not re.match(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$", repo_full_name):
# Validate repo path format:
# GitHub: owner/repo (2 segments)
# GitLab: group/subgroup/.../project (2+ segments, supports nested groups)
if repo_provider == "github":
valid = re.match(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$", repo_full_name)
else:
valid = re.match(r"^[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+){1,}$", repo_full_name)

if not valid:
logger.error(
"cursor_webhook.repo_format_invalid",
extra={"agent_id": agent_id, "source": source},
Expand Down
3 changes: 1 addition & 2 deletions src/sentry/seer/autofix/coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@
logger = logging.getLogger(__name__)


# Follows the GitHub branch name rules:
# Follows Git branch name rules:
# https://docs.github.com/en/get-started/using-git/dealing-with-special-characters-in-branch-and-tag-names#naming-branches-and-tags
# As our coding agent integration only supports launching on GitHub right now.
VALID_BRANCH_NAME_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_/"


Expand Down
131 changes: 131 additions & 0 deletions tests/sentry/integrations/cursor/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,134 @@ def test_launch_default_auto_create_pr(self, mock_post: Mock) -> None:
# Verify the payload contains autoCreatePr=False (the default)
payload = call_kwargs["data"]
assert payload["target"]["autoCreatePr"] is False

@patch.object(CursorAgentClient, "post")
def test_launch_gitlab_repo(self, mock_post: Mock) -> None:
"""Test that launch() builds a GitLab URL and sets openAsCursorGithubApp=False"""
mock_response = Mock()
mock_response.json = {
"id": "agent_456",
"status": "running",
"name": "GitLab Agent",
"createdAt": "2023-01-01T00:00:00Z",
"source": {
"repository": "https://gitlab.com/kencove/operations/odoo",
"ref": "16.0",
},
"target": {
"url": "https://cursor.com/agent/456",
"autoCreatePr": True,
"branchName": "fix-bug-456",
},
}
mock_post.return_value = mock_response

gitlab_repo = SeerRepoDefinition(
integration_id="222",
provider="integrations:gitlab",
owner="kencove/operations",
name="odoo",
external_id="gitlab.com:12345",
branch_name="16.0",
)

request = CodingAgentLaunchRequest(
prompt="Fix this bug",
repository=gitlab_repo,
branch_name="fix-bug-456",
auto_create_pr=True,
)

self.cursor_client.launch(webhook_url=self.webhook_url, request=request)

mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
payload = call_kwargs["data"]

# GitLab URL should use the instance from external_id
assert payload["source"]["repository"] == "https://gitlab.com/kencove/operations/odoo"
# openAsCursorGithubApp should be False for GitLab
assert payload["target"]["openAsCursorGithubApp"] is False

@patch.object(CursorAgentClient, "post")
def test_launch_self_hosted_gitlab_repo(self, mock_post: Mock) -> None:
"""Test that launch() handles self-hosted GitLab instances"""
mock_response = Mock()
mock_response.json = {
"id": "agent_789",
"status": "running",
"name": "Self-hosted GitLab Agent",
"createdAt": "2023-01-01T00:00:00Z",
"source": {
"repository": "https://gitlab.forgeflow.io/kencove/project",
"ref": "main",
},
"target": {
"url": "https://cursor.com/agent/789",
"autoCreatePr": False,
"branchName": "fix-bug-789",
},
}
mock_post.return_value = mock_response

gitlab_repo = SeerRepoDefinition(
integration_id="333",
provider="integrations:gitlab",
owner="kencove",
name="project",
external_id="gitlab.forgeflow.io:128",
branch_name="main",
)

request = CodingAgentLaunchRequest(
prompt="Fix this bug",
repository=gitlab_repo,
branch_name="fix-bug-789",
)

self.cursor_client.launch(webhook_url=self.webhook_url, request=request)

mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
payload = call_kwargs["data"]

# Should use the self-hosted instance from external_id
assert payload["source"]["repository"] == "https://gitlab.forgeflow.io/kencove/project"
assert payload["target"]["openAsCursorGithubApp"] is False

@patch.object(CursorAgentClient, "post")
def test_launch_github_repo_sets_github_app_true(self, mock_post: Mock) -> None:
"""Test that GitHub repos still set openAsCursorGithubApp=True"""
mock_response = Mock()
mock_response.json = {
"id": "agent_123",
"status": "running",
"name": "Test Agent",
"createdAt": "2023-01-01T00:00:00Z",
"source": {
"repository": "https://github.com/getsentry/sentry",
"ref": "main",
},
"target": {
"url": "https://cursor.com/agent/123",
"autoCreatePr": True,
"branchName": "fix-bug-123",
},
}
mock_post.return_value = mock_response

request = CodingAgentLaunchRequest(
prompt="Fix this bug",
repository=self.repo_definition,
branch_name="fix-bug-123",
auto_create_pr=True,
)

self.cursor_client.launch(webhook_url=self.webhook_url, request=request)

mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
payload = call_kwargs["data"]

assert payload["source"]["repository"] == "https://github.com/getsentry/sentry"
assert payload["target"]["openAsCursorGithubApp"] is True
Loading
Loading