Skip to content

Commit 76e7bfa

Browse files
authored
feat(gitlab): Open PR comment workflow - Backend (#92091)
1 parent b59ff88 commit 76e7bfa

File tree

16 files changed

+1096
-9
lines changed

16 files changed

+1096
-9
lines changed

fixtures/gitlab.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ def create_repo(self, name, external_id=15, url=None, organization_id=None):
169169
"work_in_progress": false,
170170
"total_time_spent": 0,
171171
"human_total_time_spent": null,
172-
"human_time_estimate": null
172+
"human_time_estimate": null,
173+
"action": "open"
173174
},
174175
"labels": null,
175176
"repository": {

src/sentry/api/endpoints/organization_details.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@
207207
bool,
208208
GITLAB_COMMENT_BOT_DEFAULT,
209209
),
210+
(
211+
"gitlabOpenPRBot",
212+
"sentry:gitlab_open_pr_bot",
213+
bool,
214+
GITLAB_COMMENT_BOT_DEFAULT,
215+
),
210216
(
211217
"issueAlertsThreadFlag",
212218
"sentry:issue_alerts_thread_flag",
@@ -279,6 +285,7 @@ class OrganizationSerializer(BaseOrganizationSerializer):
279285
githubNudgeInvite = serializers.BooleanField(required=False)
280286
githubPRBot = serializers.BooleanField(required=False)
281287
gitlabPRBot = serializers.BooleanField(required=False)
288+
gitlabOpenPRBot = serializers.BooleanField(required=False)
282289
issueAlertsThreadFlag = serializers.BooleanField(required=False)
283290
metricAlertsThreadFlag = serializers.BooleanField(required=False)
284291
require2FA = serializers.BooleanField(required=False)
@@ -831,6 +838,10 @@ class OrganizationDetailsPutSerializer(serializers.Serializer):
831838
help_text="Specify `true` to allow Sentry to comment on recent pull requests suspected of causing issues. Requires a GitLab integration.",
832839
required=False,
833840
)
841+
gitlabOpenPRBot = serializers.BooleanField(
842+
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.",
843+
required=False,
844+
)
834845

835846
# slack features
836847
issueAlertsThreadFlag = serializers.BooleanField(

src/sentry/api/serializers/models/organization.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
548548
githubOpenPRBot: bool
549549
githubNudgeInvite: bool
550550
gitlabPRBot: bool
551+
gitlabOpenPRBot: bool
551552
aggregatedDataConsent: bool
552553
genAIConsent: bool
553554
isDynamicallySampled: bool
@@ -688,6 +689,9 @@ def serialize( # type: ignore[explicit-override, override]
688689
obj.get_option("sentry:github_nudge_invite", GITHUB_COMMENT_BOT_DEFAULT)
689690
),
690691
"gitlabPRBot": bool(obj.get_option("sentry:gitlab_pr_bot", GITLAB_COMMENT_BOT_DEFAULT)),
692+
"gitlabOpenPRBot": bool(
693+
obj.get_option("sentry:gitlab_open_pr_bot", GITLAB_COMMENT_BOT_DEFAULT)
694+
),
691695
"genAIConsent": bool(
692696
obj.get_option("sentry:gen_ai_consent_v2024_11_14", DATA_CONSENT_DEFAULT)
693697
),

src/sentry/apidocs/examples/organization_examples.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ class OrganizationExamples:
321321
"githubOpenPRBot": True,
322322
"githubNudgeInvite": True,
323323
"gitlabPRBot": True,
324+
"gitlabOpenPRBot": True,
324325
"aggregatedDataConsent": False,
325326
"defaultAutofixAutomationTuning": "off",
326327
"issueAlertsThreadFlag": True,

src/sentry/integrations/gitlab/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,8 @@ def get_blame_for_files(
413413
"org_integration_id": self.org_integration_id,
414414
},
415415
)
416+
417+
def get_pr_diffs(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]]:
418+
project_id = repo.config["project_id"]
419+
path = GitLabApiClientPath.build_pr_diffs(project=project_id, pr_key=pr.key, unidiff=True)
420+
return self.get(path)

src/sentry/integrations/gitlab/integration.py

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
from collections.abc import Callable, Mapping
45
from typing import Any
56
from urllib.parse import urlparse
@@ -21,9 +22,17 @@
2122
)
2223
from sentry.integrations.services.repository.model import RpcRepository
2324
from sentry.integrations.source_code_management.commit_context import (
25+
OPEN_PR_MAX_FILES_CHANGED,
26+
OPEN_PR_MAX_LINES_CHANGED,
27+
OPEN_PR_METRICS_BASE,
2428
CommitContextIntegration,
29+
OpenPRCommentWorkflow,
2530
PRCommentWorkflow,
31+
PullRequestFile,
32+
PullRequestIssue,
33+
_open_pr_comment_log,
2634
)
35+
from sentry.integrations.source_code_management.language_parsers import PATCH_PARSERS
2736
from sentry.integrations.source_code_management.repository import RepositoryIntegration
2837
from sentry.integrations.types import IntegrationProviderSlug
2938
from sentry.models.group import Group
@@ -37,17 +46,21 @@
3746
IntegrationProviderError,
3847
)
3948
from sentry.snuba.referrer import Referrer
40-
from sentry.types.referrer_ids import GITLAB_PR_BOT_REFERRER
49+
from sentry.templatetags.sentry_helpers import small_count
50+
from sentry.types.referrer_ids import GITLAB_OPEN_PR_BOT_REFERRER, GITLAB_PR_BOT_REFERRER
4151
from sentry.users.models.identity import Identity
4252
from sentry.utils import metrics
4353
from sentry.utils.hashlib import sha1_text
4454
from sentry.utils.http import absolute_uri
55+
from sentry.utils.patch_set import patch_to_file_modifications
4556
from sentry.web.helpers import render_to_response
4657

4758
from .client import GitLabApiClient, GitLabSetupApiClient
4859
from .issues import GitlabIssuesSpec
4960
from .repository import GitlabRepositoryProvider
5061

62+
logger = logging.getLogger("sentry.integrations.gitlab")
63+
5164
DESCRIPTION = """
5265
Connect your Sentry organization to an organization in your GitLab instance or gitlab.com, enabling the following features:
5366
"""
@@ -204,6 +217,9 @@ def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]:
204217
def get_pr_comment_workflow(self) -> PRCommentWorkflow:
205218
return GitlabPRCommentWorkflow(integration=self)
206219

220+
def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow:
221+
return GitlabOpenPRCommentWorkflow(integration=self)
222+
207223

208224
MERGED_PR_COMMENT_BODY_TEMPLATE = """\
209225
## Suspect Issues
@@ -258,6 +274,217 @@ def get_comment_data(
258274
}
259275

260276

277+
OPEN_PR_COMMENT_BODY_TEMPLATE = """\
278+
## 🔍 Existing Issues For Review
279+
Your merge request is modifying functions with the following pre-existing issues:
280+
281+
{issue_tables}"""
282+
283+
OPEN_PR_ISSUE_TABLE_TEMPLATE = """\
284+
📄 File: **{filename}**
285+
286+
| Function | Unhandled Issue |
287+
| :------- | :----- |
288+
{issue_rows}"""
289+
290+
OPEN_PR_ISSUE_TABLE_TOGGLE_TEMPLATE = """\
291+
<details>
292+
<summary><b>📄 File: {filename} (Click to Expand)</b></summary>
293+
294+
| Function | Unhandled Issue |
295+
| :------- | :----- |
296+
{issue_rows}
297+
</details>"""
298+
299+
OPEN_PR_ISSUE_DESCRIPTION_LENGTH = 52
300+
301+
302+
class GitlabOpenPRCommentWorkflow(OpenPRCommentWorkflow):
303+
integration: GitlabIntegration
304+
organization_option_key = "sentry:gitlab_open_pr_bot"
305+
referrer = Referrer.GITLAB_PR_COMMENT_BOT
306+
referrer_id = GITLAB_OPEN_PR_BOT_REFERRER
307+
308+
def safe_for_comment(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]]:
309+
client = self.integration.get_client()
310+
311+
try:
312+
diffs = client.get_pr_diffs(repo=repo, pr=pr)
313+
except ApiError as e:
314+
logger.info(
315+
_open_pr_comment_log(
316+
integration_name=self.integration.integration_name, suffix="api_error"
317+
)
318+
)
319+
if e.code == 404:
320+
metrics.incr(
321+
OPEN_PR_METRICS_BASE.format(
322+
integration=self.integration.integration_name, key="api_error"
323+
),
324+
tags={"type": "missing_pr", "code": e.code},
325+
)
326+
else:
327+
metrics.incr(
328+
OPEN_PR_METRICS_BASE.format(
329+
integration=self.integration.integration_name, key="api_error"
330+
),
331+
tags={"type": "unknown_api_error", "code": e.code},
332+
)
333+
logger.exception(
334+
_open_pr_comment_log(
335+
integration_name=self.integration.integration_name,
336+
suffix="unknown_api_error",
337+
),
338+
extra={"error": str(e)},
339+
)
340+
return []
341+
342+
changed_file_count = 0
343+
changed_lines_count = 0
344+
filtered_diffs = []
345+
346+
patch_parsers = PATCH_PARSERS
347+
348+
for diff in diffs:
349+
filename = diff["new_path"]
350+
# we only count the file if it's modified and if the file extension is in the list of supported file extensions
351+
# we cannot look at deleted or newly added files because we cannot extract functions from the diffs
352+
353+
if filename.split(".")[-1] not in patch_parsers:
354+
continue
355+
356+
try:
357+
file_modifications = patch_to_file_modifications(diff["diff"])
358+
except Exception:
359+
logger.exception(
360+
_open_pr_comment_log(
361+
integration_name=self.integration.integration_name,
362+
suffix="patch_parsing_error",
363+
),
364+
)
365+
continue
366+
367+
if not file_modifications.modified:
368+
continue
369+
370+
changed_file_count += len(file_modifications.modified)
371+
changed_lines_count += sum(
372+
modification.lines_modified for modification in file_modifications.modified
373+
)
374+
375+
filtered_diffs.append(diff)
376+
377+
if changed_file_count > OPEN_PR_MAX_FILES_CHANGED:
378+
metrics.incr(
379+
OPEN_PR_METRICS_BASE.format(
380+
integration=self.integration.integration_name, key="rejected_comment"
381+
),
382+
tags={"reason": "too_many_files"},
383+
)
384+
return []
385+
if changed_lines_count > OPEN_PR_MAX_LINES_CHANGED:
386+
metrics.incr(
387+
OPEN_PR_METRICS_BASE.format(
388+
integration=self.integration.integration_name, key="rejected_comment"
389+
),
390+
tags={"reason": "too_many_lines"},
391+
)
392+
return []
393+
394+
return filtered_diffs
395+
396+
def get_pr_files_safe_for_comment(
397+
self, repo: Repository, pr: PullRequest
398+
) -> list[PullRequestFile]:
399+
pr_diffs = self.safe_for_comment(repo=repo, pr=pr)
400+
401+
if len(pr_diffs) == 0:
402+
logger.info(
403+
_open_pr_comment_log(
404+
integration_name=self.integration.integration_name,
405+
suffix="not_safe_for_comment",
406+
),
407+
extra={"file_count": len(pr_diffs)},
408+
)
409+
metrics.incr(
410+
OPEN_PR_METRICS_BASE.format(
411+
integration=self.integration.integration_name, key="error"
412+
),
413+
tags={"type": "unsafe_for_comment"},
414+
)
415+
return []
416+
417+
pr_files = [
418+
PullRequestFile(filename=diff["new_path"], patch=diff["diff"]) for diff in pr_diffs
419+
]
420+
421+
logger.info(
422+
_open_pr_comment_log(
423+
integration_name=self.integration.integration_name,
424+
suffix="pr_filenames",
425+
),
426+
extra={"count": len(pr_files)},
427+
)
428+
429+
return pr_files
430+
431+
def get_comment_data(self, comment_body: str) -> dict[str, Any]:
432+
return {
433+
"body": comment_body,
434+
}
435+
436+
@staticmethod
437+
def format_comment_url(url: str, referrer: str) -> str:
438+
return url + "?referrer=" + referrer
439+
440+
@staticmethod
441+
def format_open_pr_comment(issue_tables: list[str]) -> str:
442+
return OPEN_PR_COMMENT_BODY_TEMPLATE.format(issue_tables="\n".join(issue_tables))
443+
444+
@staticmethod
445+
def format_open_pr_comment_subtitle(title_length, subtitle):
446+
# the title length + " " + subtitle should be <= 52
447+
subtitle_length = OPEN_PR_ISSUE_DESCRIPTION_LENGTH - title_length - 1
448+
return (
449+
subtitle[: subtitle_length - 3] + "..." if len(subtitle) > subtitle_length else subtitle
450+
)
451+
452+
def format_issue_table(
453+
self,
454+
diff_filename: str,
455+
issues: list[PullRequestIssue],
456+
patch_parsers: dict[str, Any],
457+
toggle: bool,
458+
) -> str:
459+
language_parser = patch_parsers.get(diff_filename.split(".")[-1], None)
460+
461+
if not language_parser:
462+
return ""
463+
464+
issue_row_template = language_parser.issue_row_template
465+
466+
issue_rows = "\n".join(
467+
[
468+
issue_row_template.format(
469+
title=issue.title,
470+
subtitle=self.format_open_pr_comment_subtitle(len(issue.title), issue.subtitle),
471+
url=self.format_comment_url(issue.url, GITLAB_OPEN_PR_BOT_REFERRER),
472+
event_count=small_count(issue.event_count),
473+
function_name=issue.function_name,
474+
affected_users=small_count(issue.affected_users),
475+
)
476+
for issue in issues
477+
]
478+
)
479+
480+
if toggle:
481+
return OPEN_PR_ISSUE_TABLE_TOGGLE_TEMPLATE.format(
482+
filename=diff_filename, issue_rows=issue_rows
483+
)
484+
485+
return OPEN_PR_ISSUE_TABLE_TEMPLATE.format(filename=diff_filename, issue_rows=issue_rows)
486+
487+
261488
class InstallationForm(forms.Form):
262489
url = forms.CharField(
263490
label=_("GitLab URL"),

src/sentry/integrations/gitlab/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Mapping
22
from datetime import datetime
3+
from urllib.parse import urlencode
34

45
from sentry.shared_integrations.response.base import BaseApiResponse
56

@@ -39,6 +40,7 @@ class GitLabApiClientPath:
3940
update_issue_note = "/projects/{project}/issues/{issue_id}/notes/{note_id}"
4041
create_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes"
4142
update_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes/{note_id}"
43+
pr_diffs = "/projects/{project}/merge_requests/{pr_key}/diffs"
4244
project = "/projects/{project}"
4345
project_issues = "/projects/{project}/issues"
4446
project_hooks = "/projects/{project}/hooks"
@@ -50,6 +52,14 @@ class GitLabApiClientPath:
5052
def build_api_url(base_url, path):
5153
return f"{base_url.rstrip('/')}{API_VERSION}{path}"
5254

55+
@classmethod
56+
def build_pr_diffs(cls, project: str, pr_key: str, unidiff: bool = False) -> str:
57+
params = {}
58+
if unidiff:
59+
params["unidiff"] = "true"
60+
61+
return f"{cls.pr_diffs.format(project=project, pr_key=pr_key)}?{urlencode(params)}"
62+
5363

5464
def get_rate_limit_info_from_response(
5565
response: BaseApiResponse,

0 commit comments

Comments
 (0)