Skip to content
Merged
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
108 changes: 108 additions & 0 deletions fixtures/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,114 @@ def create_gitlab_repo(
"""


ISSUE_ASSIGNED_EVENT = b"""{
"object_kind": "issue",
"event_type": "issue",
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
"email": "admin@example.com"
},
"project": {
"id": 15,
"name": "Sentry",
"description": "",
"web_url": "http://example.com/cool-group/sentry",
"avatar_url": null,
"git_ssh_url": "git@example.com:cool-group/sentry.git",
"git_http_url": "http://example.com/cool-group/sentry.git",
"namespace": "cool-group",
"visibility_level": 0,
"path_with_namespace": "cool-group/sentry",
"default_branch": "master",
"homepage": "http://example.com/cool-group/sentry",
"url": "git@example.com:cool-group/sentry.git",
"ssh_url": "git@example.com:cool-group/sentry.git",
"http_url": "http://example.com/cool-group/sentry.git"
},
"object_attributes": {
"id": 301,
"title": "Test issue",
"assignee_ids": [1],
"assignee_id": 1,
"author_id": 1,
"project_id": 15,
"created_at": "2023-01-01 00:00:00 UTC",
"updated_at": "2023-01-01 00:00:00 UTC",
"position": 0,
"branch_name": null,
"description": "Test issue description",
"milestone_id": null,
"state": "opened",
"iid": 23,
"url": "http://example.com/cool-group/sentry/issues/23",
"action": "update"
},
"assignees": [
{
"id": 1,
"name": "Administrator",
"username": "root",
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
"email": "admin@example.com"
}
],
"labels": []
}
"""

ISSUE_UNASSIGNED_EVENT = b"""{
"object_kind": "issue",
"event_type": "issue",
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
"email": "admin@example.com"
},
"project": {
"id": 15,
"name": "Sentry",
"description": "",
"web_url": "http://example.com/cool-group/sentry",
"avatar_url": null,
"git_ssh_url": "git@example.com:cool-group/sentry.git",
"git_http_url": "http://example.com/cool-group/sentry.git",
"namespace": "cool-group",
"visibility_level": 0,
"path_with_namespace": "cool-group/sentry",
"default_branch": "master",
"homepage": "http://example.com/cool-group/sentry",
"url": "git@example.com:cool-group/sentry.git",
"ssh_url": "git@example.com:cool-group/sentry.git",
"http_url": "http://example.com/cool-group/sentry.git"
},
"object_attributes": {
"id": 301,
"title": "Test issue",
"assignee_ids": [],
"assignee_id": null,
"author_id": 1,
"project_id": 15,
"created_at": "2023-01-01 00:00:00 UTC",
"updated_at": "2023-01-01 00:00:00 UTC",
"position": 0,
"branch_name": null,
"description": "Test issue description",
"milestone_id": null,
"state": "opened",
"iid": 23,
"url": "http://example.com/cool-group/sentry/issues/23",
"action": "update"
},
"assignees": [],
"labels": []
}
"""

COMPARE_RESPONSE = r"""
{
"commit": {
Expand Down
1 change: 1 addition & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def register_temporary_features(manager: FeatureManager) -> None:
# Project Management Integrations Feature Parity Flags
manager.add("organizations:integrations-github-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable inviting billing members to organizations at the member limit.
manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False)
# Enable inviting members to organizations.
Expand Down
51 changes: 51 additions & 0 deletions src/sentry/integrations/gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ def get_user(self):
"""
return self.get(GitLabApiClientPath.user)

def search_users(self, username: str):
"""Search for a user by username

See https://docs.gitlab.com/ee/api/users.html#for-non-administrator-users
"""
return self.get(GitLabApiClientPath.users, params={"username": username})

def search_projects(self, group=None, query=None, simple=True):
"""Get projects

Expand Down Expand Up @@ -275,6 +282,23 @@ def search_project_issues(self, project_id, query, iids=None):

return self.get(path, params={"scope": "all", "search": query, "iids": iids})

def update_issue_assignees(self, project_id: str, issue_iid: str, assignee_ids: list[int]):
"""Update an issue's assignees

See https://docs.gitlab.com/ee/api/issues.html#edit-an-issue
"""
data = {"assignee_ids": assignee_ids}
path = GitLabApiClientPath.issue.format(project=project_id, issue=issue_iid)
return self.put(path, data=data)

def update_issue_status(self, project_id: str, issue_iid: str, state: str):
"""Update an issue's status

See https://docs.gitlab.com/ee/api/issues.html#edit-an-issue
"""
path = GitLabApiClientPath.issue.format(project=project_id, issue=issue_iid)
return self.put(path, data={"state_event": state})

def create_project_webhook(self, project_id):
"""Create a webhook on a project

Expand All @@ -288,12 +312,39 @@ def create_project_webhook(self, project_id):
"token": "{}:{}".format(model.external_id, model.metadata["webhook_secret"]),
"merge_requests_events": True,
"push_events": True,
"issues_events": True,
"enable_ssl_verification": model.metadata["verify_ssl"],
}
resp = self.post(path, data=data)

return resp["id"]

def get_project_webhooks(self, project_id):
"""List webhooks for a project

See https://docs.gitlab.com/ee/api/projects.html#list-project-hooks
"""
path = GitLabApiClientPath.project_hooks.format(project=project_id)
return self.get(path)

def update_project_webhook(self, project_id, hook_id):
"""Update a webhook on a project to include all required events

See https://docs.gitlab.com/ee/api/projects.html#edit-project-hook
"""
path = GitLabApiClientPath.project_hook.format(project=project_id, hook_id=hook_id)
hook_uri = reverse("sentry-extensions-gitlab-webhook")
model = self.installation.model
data = {
"url": absolute_uri(hook_uri),
"token": "{}:{}".format(model.external_id, model.metadata["webhook_secret"]),
"merge_requests_events": True,
"push_events": True,
"issues_events": True,
"enable_ssl_verification": model.metadata["verify_ssl"],
}
return self.put(path, data=data)

def delete_project_webhook(self, project_id, hook_id):
"""Delete a webhook from a project

Expand Down
81 changes: 79 additions & 2 deletions src/sentry/integrations/gitlab/integration.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import logging
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from typing import Any
from urllib.parse import urlparse

Expand All @@ -10,6 +10,7 @@
from django.http.response import HttpResponseBase
from django.utils.translation import gettext_lazy as _

from sentry import features
from sentry.identity.gitlab.provider import GitlabIdentityProvider, get_oauth_data, get_user_info
from sentry.identity.pipeline import IdentityPipeline
from sentry.integrations.base import (
Expand All @@ -21,6 +22,7 @@
)
from sentry.integrations.pipeline import IntegrationPipeline
from sentry.integrations.referrer_ids import GITLAB_PR_BOT_REFERRER
from sentry.integrations.services.integration import integration_service
from sentry.integrations.services.repository.model import RpcRepository
from sentry.integrations.source_code_management.commit_context import (
CommitContextIntegration,
Expand All @@ -32,6 +34,7 @@
from sentry.models.organization import Organization
from sentry.models.pullrequest import PullRequest
from sentry.models.repository import Repository
from sentry.organizations.services.organization import organization_service
from sentry.pipeline.views.base import PipelineView
from sentry.pipeline.views.nested import NestedPipelineView
from sentry.shared_integrations.exceptions import (
Expand All @@ -47,6 +50,7 @@
from sentry.web.helpers import render_to_response

from .client import GitLabApiClient, GitLabSetupApiClient
from .issue_sync import GitlabIssueSyncSpec
from .issues import GitlabIssuesSpec
from .repository import GitlabRepositoryProvider
from .utils import parse_gitlab_blob_url
Expand Down Expand Up @@ -110,7 +114,9 @@
)


class GitlabIntegration(RepositoryIntegration, GitlabIssuesSpec, CommitContextIntegration):
class GitlabIntegration(
RepositoryIntegration, GitlabIssuesSpec, GitlabIssueSyncSpec, CommitContextIntegration
):
codeowners_locations = ["CODEOWNERS", ".gitlab/CODEOWNERS", "docs/CODEOWNERS"]

@property
Expand Down Expand Up @@ -177,6 +183,77 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str
_, source_path = parse_gitlab_blob_url(repo.url, url)
return source_path

# IssueSyncIntegration methods

def _get_organization_config_default_values(self) -> list[dict[str, Any]]:
config: list[dict[str, Any]] = []

if self.check_feature_flag():
config.extend(
[
{
"name": self.inbound_assignee_key,
"type": "boolean",
"label": _("Sync GitLab Assignment to Sentry"),
"help": _(
"When an issue is assigned in GitLab, assign its linked Sentry issue to the same user."
),
"default": False,
},
{
"name": self.outbound_assignee_key,
"type": "boolean",
"label": _("Sync Sentry Assignment to GitLab"),
"help": _(
"When an issue is assigned in Sentry, assign its linked GitLab issue to the same user."
),
"default": False,
},
{
"name": self.comment_key,
"type": "boolean",
"label": _("Sync Sentry Comments to GitLab"),
"help": _("Post comments from Sentry issues to linked GitLab issues"),
},
]
)

return config

def get_organization_config(self) -> list[dict[str, Any]]:
config = self._get_organization_config_default_values()

context = organization_service.get_organization_by_id(
id=self.organization_id, include_projects=False, include_teams=False
)
assert context, "organizationcontext must exist to get org"
organization = context.organization

has_issue_sync = features.has("organizations:integrations-issue-sync", organization)

if not has_issue_sync:
for field in config:
field["disabled"] = True
field["disabledReason"] = _(
"Your organization does not have access to this feature"
)

return config

def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
if not self.org_integration:
return

config = self.org_integration.config

config.update(data)
org_integration = integration_service.update_organization_integration(
org_integration_id=self.org_integration.id,
config=config,
)
if org_integration is not None:
self.org_integration = org_integration

# CommitContextIntegration methods

def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: str) -> bool:
Expand Down
Loading
Loading