diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 00000000000000..d516397b7fb740 --- /dev/null +++ b/.gcloudignore @@ -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 diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index dc2281672430c5..e7924532d1e8e4 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -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: '' @@ -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: | @@ -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 diff --git a/src/sentry/integrations/cursor/client.py b/src/sentry/integrations/cursor/client.py index f280b38a0a702d..71819bc0215ffa 100644 --- a/src/sentry/integrations/cursor/client.py +++ b/src/sentry/integrations/cursor/client.py @@ -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" @@ -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, ), ) diff --git a/src/sentry/integrations/cursor/webhooks/handler.py b/src/sentry/integrations/cursor/webhooks/handler.py index 5b57c817432548..9ed9eb3f31f34c 100644 --- a/src/sentry/integrations/cursor/webhooks/handler.py +++ b/src/sentry/integrations/cursor/webhooks/handler.py @@ -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}, diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index f76796839f755c..388fd33f7906a1 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -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-_/" diff --git a/tests/sentry/integrations/cursor/test_client.py b/tests/sentry/integrations/cursor/test_client.py index a4ba9cb0b9734c..f00bd3228373db 100644 --- a/tests/sentry/integrations/cursor/test_client.py +++ b/tests/sentry/integrations/cursor/test_client.py @@ -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 diff --git a/tests/sentry/integrations/cursor/test_webhook.py b/tests/sentry/integrations/cursor/test_webhook.py index 724cf46a4025dc..4504159102dc93 100644 --- a/tests/sentry/integrations/cursor/test_webhook.py +++ b/tests/sentry/integrations/cursor/test_webhook.py @@ -192,8 +192,8 @@ def test_repo_variants_and_validation(self, mock_update_state): assert resp.status_code == 204 mock_update_state.assert_not_called() - # Non-github host - payload = self._build_status_payload(repo="https://gitlab.com/testorg/testrepo") + # Unsupported host (not github or gitlab) + payload = self._build_status_payload(repo="https://bitbucket.org/testorg/testrepo") body = orjson.dumps(payload) headers = self._signed_headers(body) with Feature({"organizations:seer-coding-agent-integrations": True}): @@ -229,6 +229,85 @@ def test_repo_variants_and_validation(self, mock_update_state): assert resp.status_code == 204 assert mock_update_state.call_count == 1 + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_gitlab_repo_accepted(self, mock_update_state): + """GitLab repos should be accepted and set provider to integrations:gitlab""" + payload = self._build_status_payload( + repo="https://gitlab.com/kencove/operations/odoo", + pr_url="https://gitlab.com/kencove/operations/odoo/-/merge_requests/1", + ) + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + resp = self._post_with_headers(body, headers) + + assert resp.status_code == 204 + assert mock_update_state.call_count == 1 + _, kwargs = mock_update_state.call_args + result = kwargs["result"] + assert result.repo_provider == "integrations:gitlab" + assert result.repo_full_name == "kencove/operations/odoo" + + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_gitlab_nested_groups(self, mock_update_state): + """GitLab repos with deeply nested groups should be accepted""" + payload = self._build_status_payload( + repo="https://gitlab.com/org/group/subgroup/project", + pr_url=None, + ) + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + resp = self._post_with_headers(body, headers) + + assert resp.status_code == 204 + assert mock_update_state.call_count == 1 + _, kwargs = mock_update_state.call_args + result = kwargs["result"] + assert result.repo_full_name == "org/group/subgroup/project" + assert result.repo_provider == "integrations:gitlab" + + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_self_hosted_gitlab_accepted(self, mock_update_state): + """Self-hosted GitLab instances (with 'gitlab' in domain) should be accepted""" + payload = self._build_status_payload( + repo="https://gitlab.forgeflow.io/kencove/project", + pr_url=None, + ) + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + resp = self._post_with_headers(body, headers) + + assert resp.status_code == 204 + assert mock_update_state.call_count == 1 + _, kwargs = mock_update_state.call_args + result = kwargs["result"] + assert result.repo_provider == "integrations:gitlab" + assert result.repo_full_name == "kencove/project" + + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_gitlab_no_scheme(self, mock_update_state): + """GitLab repo without https:// scheme should be accepted""" + payload = self._build_status_payload( + repo="gitlab.com/testorg/testrepo", + pr_url=None, + ) + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + resp = self._post_with_headers(body, headers) + + assert resp.status_code == 204 + assert mock_update_state.call_count == 1 + _, kwargs = mock_update_state.call_args + result = kwargs["result"] + assert result.repo_provider == "integrations:gitlab" + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") def test_signature_without_prefix(self, mock_update_state): payload = self._build_status_payload(status="FINISHED")