Skip to content

feat(gitlab): Open PR comment workflow - Backend #92091

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
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
3 changes: 2 additions & 1 deletion fixtures/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ def create_repo(self, name, external_id=15, url=None, organization_id=None):
"work_in_progress": false,
"total_time_spent": 0,
"human_total_time_spent": null,
"human_time_estimate": null
"human_time_estimate": null,
"action": "open"
},
"labels": null,
"repository": {
Expand Down
11 changes: 11 additions & 0 deletions src/sentry/api/endpoints/organization_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@
bool,
GITLAB_COMMENT_BOT_DEFAULT,
),
(
"gitlabOpenPRBot",
"sentry:gitlab_open_pr_bot",
bool,
GITLAB_COMMENT_BOT_DEFAULT,
),
(
"issueAlertsThreadFlag",
"sentry:issue_alerts_thread_flag",
Expand Down Expand Up @@ -279,6 +285,7 @@ class OrganizationSerializer(BaseOrganizationSerializer):
githubNudgeInvite = serializers.BooleanField(required=False)
githubPRBot = serializers.BooleanField(required=False)
gitlabPRBot = serializers.BooleanField(required=False)
gitlabOpenPRBot = serializers.BooleanField(required=False)
issueAlertsThreadFlag = serializers.BooleanField(required=False)
metricAlertsThreadFlag = serializers.BooleanField(required=False)
require2FA = serializers.BooleanField(required=False)
Expand Down Expand Up @@ -831,6 +838,10 @@ class OrganizationDetailsPutSerializer(serializers.Serializer):
help_text="Specify `true` to allow Sentry to comment on recent pull requests suspected of causing issues. Requires a GitLab integration.",
required=False,
)
gitlabOpenPRBot = serializers.BooleanField(
help_text="Specify `true` to allow Sentry to comment on open pull requests to show recent error issues for the code being changed. Requires a GitLab integration.",
required=False,
)

# slack features
issueAlertsThreadFlag = serializers.BooleanField(
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/api/serializers/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
githubOpenPRBot: bool
githubNudgeInvite: bool
gitlabPRBot: bool
gitlabOpenPRBot: bool
aggregatedDataConsent: bool
genAIConsent: bool
isDynamicallySampled: bool
Expand Down Expand Up @@ -688,6 +689,9 @@ def serialize( # type: ignore[explicit-override, override]
obj.get_option("sentry:github_nudge_invite", GITHUB_COMMENT_BOT_DEFAULT)
),
"gitlabPRBot": bool(obj.get_option("sentry:gitlab_pr_bot", GITLAB_COMMENT_BOT_DEFAULT)),
"gitlabOpenPRBot": bool(
obj.get_option("sentry:gitlab_open_pr_bot", GITLAB_COMMENT_BOT_DEFAULT)
),
"genAIConsent": bool(
obj.get_option("sentry:gen_ai_consent_v2024_11_14", DATA_CONSENT_DEFAULT)
),
Expand Down
1 change: 1 addition & 0 deletions src/sentry/apidocs/examples/organization_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ class OrganizationExamples:
"githubOpenPRBot": True,
"githubNudgeInvite": True,
"gitlabPRBot": True,
"gitlabOpenPRBot": True,
"aggregatedDataConsent": False,
"defaultAutofixAutomationTuning": "off",
"issueAlertsThreadFlag": True,
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/integrations/gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,8 @@ def get_blame_for_files(
"org_integration_id": self.org_integration_id,
},
)

def get_pr_diffs(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]]:
project_id = repo.config["project_id"]
path = GitLabApiClientPath.build_pr_diffs(project=project_id, pr_key=pr.key, unidiff=True)
return self.get(path)
229 changes: 228 additions & 1 deletion src/sentry/integrations/gitlab/integration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
from collections.abc import Callable, Mapping
from typing import Any
from urllib.parse import urlparse
Expand All @@ -21,9 +22,17 @@
)
from sentry.integrations.services.repository.model import RpcRepository
from sentry.integrations.source_code_management.commit_context import (
OPEN_PR_MAX_FILES_CHANGED,
OPEN_PR_MAX_LINES_CHANGED,
OPEN_PR_METRICS_BASE,
CommitContextIntegration,
OpenPRCommentWorkflow,
PRCommentWorkflow,
PullRequestFile,
PullRequestIssue,
_open_pr_comment_log,
)
from sentry.integrations.source_code_management.language_parsers import PATCH_PARSERS
from sentry.integrations.source_code_management.repository import RepositoryIntegration
from sentry.models.group import Group
from sentry.models.organization import Organization
Expand All @@ -36,17 +45,21 @@
IntegrationProviderError,
)
from sentry.snuba.referrer import Referrer
from sentry.types.referrer_ids import GITLAB_PR_BOT_REFERRER
from sentry.templatetags.sentry_helpers import small_count
from sentry.types.referrer_ids import GITLAB_OPEN_PR_BOT_REFERRER, GITLAB_PR_BOT_REFERRER
from sentry.users.models.identity import Identity
from sentry.utils import metrics
from sentry.utils.hashlib import sha1_text
from sentry.utils.http import absolute_uri
from sentry.utils.patch_set import patch_to_file_modifications
from sentry.web.helpers import render_to_response

from .client import GitLabApiClient, GitLabSetupApiClient
from .issues import GitlabIssuesSpec
from .repository import GitlabRepositoryProvider

logger = logging.getLogger("sentry.integrations.gitlab")

DESCRIPTION = """
Connect your Sentry organization to an organization in your GitLab instance or gitlab.com, enabling the following features:
"""
Expand Down Expand Up @@ -203,6 +216,9 @@ def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]:
def get_pr_comment_workflow(self) -> PRCommentWorkflow:
return GitlabPRCommentWorkflow(integration=self)

def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow:
return GitlabOpenPRCommentWorkflow(integration=self)


MERGED_PR_COMMENT_BODY_TEMPLATE = """\
## Suspect Issues
Expand Down Expand Up @@ -257,6 +273,217 @@ def get_comment_data(
}


OPEN_PR_COMMENT_BODY_TEMPLATE = """\
## 🔍 Existing Issues For Review
Your merge request is modifying functions with the following pre-existing issues:

{issue_tables}"""

OPEN_PR_ISSUE_TABLE_TEMPLATE = """\
📄 File: **{filename}**

| Function | Unhandled Issue |
| :------- | :----- |
{issue_rows}"""

OPEN_PR_ISSUE_TABLE_TOGGLE_TEMPLATE = """\
<details>
<summary><b>📄 File: {filename} (Click to Expand)</b></summary>

| Function | Unhandled Issue |
| :------- | :----- |
{issue_rows}
</details>"""

OPEN_PR_ISSUE_DESCRIPTION_LENGTH = 52


class GitlabOpenPRCommentWorkflow(OpenPRCommentWorkflow):
integration: GitlabIntegration
organization_option_key = "sentry:gitlab_open_pr_bot"
referrer = Referrer.GITLAB_PR_COMMENT_BOT
referrer_id = GITLAB_OPEN_PR_BOT_REFERRER

def safe_for_comment(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]]:
client = self.integration.get_client()

try:
diffs = client.get_pr_diffs(repo=repo, pr=pr)
except ApiError as e:
logger.info(
_open_pr_comment_log(
integration_name=self.integration.integration_name, suffix="api_error"
)
)
if e.code == 404:
metrics.incr(
OPEN_PR_METRICS_BASE.format(
integration=self.integration.integration_name, key="api_error"
),
tags={"type": "missing_pr", "code": e.code},
)
else:
metrics.incr(
OPEN_PR_METRICS_BASE.format(
integration=self.integration.integration_name, key="api_error"
),
tags={"type": "unknown_api_error", "code": e.code},
)
logger.exception(
_open_pr_comment_log(
integration_name=self.integration.integration_name,
suffix="unknown_api_error",
),
extra={"error": str(e)},
)
return []

changed_file_count = 0
changed_lines_count = 0
filtered_diffs = []

patch_parsers = PATCH_PARSERS

for diff in diffs:
filename = diff["new_path"]
# we only count the file if it's modified and if the file extension is in the list of supported file extensions
# we cannot look at deleted or newly added files because we cannot extract functions from the diffs

if filename.split(".")[-1] not in patch_parsers:
continue

try:
file_modifications = patch_to_file_modifications(diff["diff"])
except Exception:
logger.exception(
_open_pr_comment_log(
integration_name=self.integration.integration_name,
suffix="patch_parsing_error",
),
)
continue

if not file_modifications.modified:
continue

changed_file_count += len(file_modifications.modified)
changed_lines_count += sum(
modification.lines_modified for modification in file_modifications.modified
)

filtered_diffs.append(diff)

if changed_file_count > OPEN_PR_MAX_FILES_CHANGED:
metrics.incr(
OPEN_PR_METRICS_BASE.format(
integration=self.integration.integration_name, key="rejected_comment"
),
tags={"reason": "too_many_files"},
)
return []
if changed_lines_count > OPEN_PR_MAX_LINES_CHANGED:
metrics.incr(
OPEN_PR_METRICS_BASE.format(
integration=self.integration.integration_name, key="rejected_comment"
),
tags={"reason": "too_many_lines"},
)
return []

return filtered_diffs

def get_pr_files_safe_for_comment(
self, repo: Repository, pr: PullRequest
) -> list[PullRequestFile]:
pr_diffs = self.safe_for_comment(repo=repo, pr=pr)

if len(pr_diffs) == 0:
logger.info(
_open_pr_comment_log(
integration_name=self.integration.integration_name,
suffix="not_safe_for_comment",
),
extra={"file_count": len(pr_diffs)},
)
metrics.incr(
OPEN_PR_METRICS_BASE.format(
integration=self.integration.integration_name, key="error"
),
tags={"type": "unsafe_for_comment"},
)
return []

pr_files = [
PullRequestFile(filename=diff["new_path"], patch=diff["diff"]) for diff in pr_diffs
]

logger.info(
_open_pr_comment_log(
integration_name=self.integration.integration_name,
suffix="pr_filenames",
),
extra={"count": len(pr_files)},
)

return pr_files

def get_comment_data(self, comment_body: str) -> dict[str, Any]:
return {
"body": comment_body,
}

@staticmethod
def format_comment_url(url: str, referrer: str) -> str:
return url + "?referrer=" + referrer

@staticmethod
def format_open_pr_comment(issue_tables: list[str]) -> str:
return OPEN_PR_COMMENT_BODY_TEMPLATE.format(issue_tables="\n".join(issue_tables))

@staticmethod
def format_open_pr_comment_subtitle(title_length, subtitle):
# the title length + " " + subtitle should be <= 52
subtitle_length = OPEN_PR_ISSUE_DESCRIPTION_LENGTH - title_length - 1
return (
subtitle[: subtitle_length - 3] + "..." if len(subtitle) > subtitle_length else subtitle
)

def format_issue_table(
self,
diff_filename: str,
issues: list[PullRequestIssue],
patch_parsers: dict[str, Any],
toggle: bool,
) -> str:
language_parser = patch_parsers.get(diff_filename.split(".")[-1], None)

if not language_parser:
return ""

issue_row_template = language_parser.issue_row_template

issue_rows = "\n".join(
[
issue_row_template.format(
title=issue.title,
subtitle=self.format_open_pr_comment_subtitle(len(issue.title), issue.subtitle),
url=self.format_comment_url(issue.url, GITLAB_OPEN_PR_BOT_REFERRER),
event_count=small_count(issue.event_count),
function_name=issue.function_name,
affected_users=small_count(issue.affected_users),
)
for issue in issues
]
)

if toggle:
return OPEN_PR_ISSUE_TABLE_TOGGLE_TEMPLATE.format(
filename=diff_filename, issue_rows=issue_rows
)

return OPEN_PR_ISSUE_TABLE_TEMPLATE.format(filename=diff_filename, issue_rows=issue_rows)


class InstallationForm(forms.Form):
url = forms.CharField(
label=_("GitLab URL"),
Expand Down
10 changes: 10 additions & 0 deletions src/sentry/integrations/gitlab/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Mapping
from datetime import datetime
from urllib.parse import urlencode

from sentry.shared_integrations.response.base import BaseApiResponse

Expand Down Expand Up @@ -39,6 +40,7 @@ class GitLabApiClientPath:
update_issue_note = "/projects/{project}/issues/{issue_id}/notes/{note_id}"
create_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes"
update_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes/{note_id}"
pr_diffs = "/projects/{project}/merge_requests/{pr_key}/diffs"
project = "/projects/{project}"
project_issues = "/projects/{project}/issues"
project_hooks = "/projects/{project}/hooks"
Expand All @@ -50,6 +52,14 @@ class GitLabApiClientPath:
def build_api_url(base_url, path):
return f"{base_url.rstrip('/')}{API_VERSION}{path}"

@classmethod
def build_pr_diffs(cls, project: str, pr_key: str, unidiff: bool = False) -> str:
params = {}
if unidiff:
params["unidiff"] = "true"

return f"{cls.pr_diffs.format(project=project, pr_key=pr_key)}?{urlencode(params)}"


def get_rate_limit_info_from_response(
response: BaseApiResponse,
Expand Down
Loading
Loading