|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
| 3 | +import logging |
3 | 4 | from collections.abc import Callable, Mapping
|
4 | 5 | from typing import Any
|
5 | 6 | from urllib.parse import urlparse
|
|
21 | 22 | )
|
22 | 23 | from sentry.integrations.services.repository.model import RpcRepository
|
23 | 24 | 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, |
24 | 28 | CommitContextIntegration,
|
| 29 | + OpenPRCommentWorkflow, |
25 | 30 | PRCommentWorkflow,
|
| 31 | + PullRequestFile, |
| 32 | + PullRequestIssue, |
| 33 | + _open_pr_comment_log, |
26 | 34 | )
|
| 35 | +from sentry.integrations.source_code_management.language_parsers import PATCH_PARSERS |
27 | 36 | from sentry.integrations.source_code_management.repository import RepositoryIntegration
|
28 | 37 | from sentry.integrations.types import IntegrationProviderSlug
|
29 | 38 | from sentry.models.group import Group
|
|
37 | 46 | IntegrationProviderError,
|
38 | 47 | )
|
39 | 48 | 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 |
41 | 51 | from sentry.users.models.identity import Identity
|
42 | 52 | from sentry.utils import metrics
|
43 | 53 | from sentry.utils.hashlib import sha1_text
|
44 | 54 | from sentry.utils.http import absolute_uri
|
| 55 | +from sentry.utils.patch_set import patch_to_file_modifications |
45 | 56 | from sentry.web.helpers import render_to_response
|
46 | 57 |
|
47 | 58 | from .client import GitLabApiClient, GitLabSetupApiClient
|
48 | 59 | from .issues import GitlabIssuesSpec
|
49 | 60 | from .repository import GitlabRepositoryProvider
|
50 | 61 |
|
| 62 | +logger = logging.getLogger("sentry.integrations.gitlab") |
| 63 | + |
51 | 64 | DESCRIPTION = """
|
52 | 65 | Connect your Sentry organization to an organization in your GitLab instance or gitlab.com, enabling the following features:
|
53 | 66 | """
|
@@ -204,6 +217,9 @@ def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]:
|
204 | 217 | def get_pr_comment_workflow(self) -> PRCommentWorkflow:
|
205 | 218 | return GitlabPRCommentWorkflow(integration=self)
|
206 | 219 |
|
| 220 | + def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow: |
| 221 | + return GitlabOpenPRCommentWorkflow(integration=self) |
| 222 | + |
207 | 223 |
|
208 | 224 | MERGED_PR_COMMENT_BODY_TEMPLATE = """\
|
209 | 225 | ## Suspect Issues
|
@@ -258,6 +274,217 @@ def get_comment_data(
|
258 | 274 | }
|
259 | 275 |
|
260 | 276 |
|
| 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 | + |
261 | 488 | class InstallationForm(forms.Form):
|
262 | 489 | url = forms.CharField(
|
263 | 490 | label=_("GitLab URL"),
|
|
0 commit comments