|
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.models.group import Group
|
29 | 38 | from sentry.models.organization import Organization
|
|
36 | 45 | IntegrationProviderError,
|
37 | 46 | )
|
38 | 47 | 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 |
40 | 50 | from sentry.users.models.identity import Identity
|
41 | 51 | from sentry.utils import metrics
|
42 | 52 | from sentry.utils.hashlib import sha1_text
|
43 | 53 | from sentry.utils.http import absolute_uri
|
| 54 | +from sentry.utils.patch_set import patch_to_file_modifications |
44 | 55 | from sentry.web.helpers import render_to_response
|
45 | 56 |
|
46 | 57 | from .client import GitLabApiClient, GitLabSetupApiClient
|
47 | 58 | from .issues import GitlabIssuesSpec
|
48 | 59 | from .repository import GitlabRepositoryProvider
|
49 | 60 |
|
| 61 | +logger = logging.getLogger("sentry.integrations.gitlab") |
| 62 | + |
50 | 63 | DESCRIPTION = """
|
51 | 64 | Connect your Sentry organization to an organization in your GitLab instance or gitlab.com, enabling the following features:
|
52 | 65 | """
|
@@ -203,6 +216,9 @@ def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]:
|
203 | 216 | def get_pr_comment_workflow(self) -> PRCommentWorkflow:
|
204 | 217 | return GitlabPRCommentWorkflow(integration=self)
|
205 | 218 |
|
| 219 | + def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow: |
| 220 | + return GitlabOpenPRCommentWorkflow(integration=self) |
| 221 | + |
206 | 222 |
|
207 | 223 | MERGED_PR_COMMENT_BODY_TEMPLATE = """\
|
208 | 224 | ## Suspect Issues
|
@@ -257,6 +273,217 @@ def get_comment_data(
|
257 | 273 | }
|
258 | 274 |
|
259 | 275 |
|
| 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 | + |
260 | 487 | class InstallationForm(forms.Form):
|
261 | 488 | url = forms.CharField(
|
262 | 489 | label=_("GitLab URL"),
|
|
0 commit comments