Skip to content

Commit 56c2ecb

Browse files
committed
feat: implementation
1 parent 19f810b commit 56c2ecb

File tree

4 files changed

+826
-4
lines changed

4 files changed

+826
-4
lines changed

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.models.group import Group
2938
from sentry.models.organization import Organization
@@ -36,17 +45,21 @@
3645
IntegrationProviderError,
3746
)
3847
from sentry.snuba.referrer import Referrer
39-
from sentry.types.referrer_ids import GITLAB_PR_BOT_REFERRER
48+
from sentry.templatetags.sentry_helpers import small_count
49+
from sentry.types.referrer_ids import GITLAB_OPEN_PR_BOT_REFERRER, GITLAB_PR_BOT_REFERRER
4050
from sentry.users.models.identity import Identity
4151
from sentry.utils import metrics
4252
from sentry.utils.hashlib import sha1_text
4353
from sentry.utils.http import absolute_uri
54+
from sentry.utils.patch_set import patch_to_file_modifications
4455
from sentry.web.helpers import render_to_response
4556

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

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

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

207223
MERGED_PR_COMMENT_BODY_TEMPLATE = """\
208224
## Suspect Issues
@@ -257,6 +273,217 @@ def get_comment_data(
257273
}
258274

259275

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

src/sentry/integrations/source_code_management/tasks.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ def open_pr_comment_workflow(pr_id: int) -> None:
299299
extra={
300300
"organization_id": org_id,
301301
"repository_id": repo.id,
302+
"file_name": file.filename,
302303
"extension": file_extension,
303304
},
304305
)
@@ -307,22 +308,35 @@ def open_pr_comment_workflow(pr_id: int) -> None:
307308
if not language_parser:
308309
logger.info(
309310
_open_pr_comment_log(integration_name=integration_name, suffix="missing_parser"),
310-
extra={"extension": file_extension},
311+
extra={"file_name": file.filename, "extension": file_extension},
311312
)
312313
metrics.incr(
313314
OPEN_PR_METRICS_BASE.format(integration=integration_name, key="missing_parser"),
314-
tags={"extension": file_extension},
315+
tags={"file_name": file.filename, "extension": file_extension},
315316
)
316317
continue
317318

318319
function_names = language_parser.extract_functions_from_patch(file.patch)
319320

321+
if file_extension == "py":
322+
logger.info(
323+
_open_pr_comment_log(integration_name=integration_name, suffix="python"),
324+
extra={
325+
"organization_id": org_id,
326+
"repository_id": repo.id,
327+
"file_name": file.filename,
328+
"extension": file_extension,
329+
"has_function_names": bool(function_names),
330+
},
331+
)
332+
320333
if file_extension in ["js", "jsx"]:
321334
logger.info(
322335
_open_pr_comment_log(integration_name=integration_name, suffix="javascript"),
323336
extra={
324337
"organization_id": org_id,
325338
"repository_id": repo.id,
339+
"file_name": file.filename,
326340
"extension": file_extension,
327341
"has_function_names": bool(function_names),
328342
},
@@ -334,6 +348,7 @@ def open_pr_comment_workflow(pr_id: int) -> None:
334348
extra={
335349
"organization_id": org_id,
336350
"repository_id": repo.id,
351+
"file_name": file.filename,
337352
"extension": file_extension,
338353
"has_function_names": bool(function_names),
339354
},
@@ -345,6 +360,7 @@ def open_pr_comment_workflow(pr_id: int) -> None:
345360
extra={
346361
"organization_id": org_id,
347362
"repository_id": repo.id,
363+
"file_name": file.filename,
348364
"extension": file_extension,
349365
"has_function_names": bool(function_names),
350366
},

0 commit comments

Comments
 (0)