blame.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. from __future__ import annotations
  2. import logging
  3. from dataclasses import asdict
  4. from datetime import timezone
  5. from typing import Any, Mapping, Optional, Sequence, Tuple, TypedDict
  6. from urllib.parse import quote
  7. from isodate import parse_datetime
  8. from sentry.integrations.gitlab.utils import (
  9. GitLabApiClientPath,
  10. GitLabRateLimitInfo,
  11. get_rate_limit_info_from_response,
  12. )
  13. from sentry.integrations.mixins.commit_context import CommitInfo, FileBlameInfo, SourceLineInfo
  14. from sentry.shared_integrations.client.base import BaseApiClient
  15. from sentry.shared_integrations.exceptions import ApiRateLimitedError
  16. from sentry.shared_integrations.exceptions.base import ApiError
  17. from sentry.shared_integrations.response.sequence import SequenceApiResponse
  18. from sentry.utils import json, metrics
  19. logger = logging.getLogger("sentry.integrations.gitlab")
  20. MINIMUM_REQUESTS = 100
  21. class GitLabCommitResponse(TypedDict):
  22. id: str
  23. message: Optional[str]
  24. committed_date: Optional[str]
  25. author_name: Optional[str]
  26. author_email: Optional[str]
  27. committer_name: Optional[str]
  28. committer_email: Optional[str]
  29. class GitLabFileBlameResponseItem(TypedDict):
  30. commit: GitLabCommitResponse
  31. lines: Sequence[str]
  32. def fetch_file_blames(
  33. client: BaseApiClient, files: Sequence[SourceLineInfo], extra: Mapping[str, Any]
  34. ) -> list[FileBlameInfo]:
  35. blames = []
  36. for i, file in enumerate(files):
  37. try:
  38. commit, rate_limit_info = _fetch_file_blame(client, file, extra)
  39. if commit:
  40. blames.append(_create_file_blame_info(commit, file))
  41. except ApiError as e:
  42. _handle_file_blame_error(e, file, extra)
  43. else:
  44. # On first iteration, make sure we have enough requests left
  45. if (
  46. i == 0
  47. and len(files) > 1
  48. and rate_limit_info
  49. and rate_limit_info.remaining < (MINIMUM_REQUESTS - len(files))
  50. ):
  51. metrics.incr("integrations.gitlab.get_blame_for_files.rate_limit")
  52. logger.error(
  53. "get_blame_for_files.rate_limit_too_low",
  54. extra={
  55. **extra,
  56. "num_files": len(files),
  57. "remaining_requests": rate_limit_info.remaining,
  58. "total_requests": rate_limit_info.limit,
  59. "next_window": rate_limit_info.next_window(),
  60. },
  61. )
  62. raise ApiRateLimitedError("Approaching GitLab API rate limit")
  63. return blames
  64. def _fetch_file_blame(
  65. client: BaseApiClient, file: SourceLineInfo, extra: Mapping[str, Any]
  66. ) -> Tuple[Optional[CommitInfo], Optional[GitLabRateLimitInfo]]:
  67. project_id = file.repo.config.get("project_id")
  68. encoded_path = quote(file.path, safe="")
  69. request_path = GitLabApiClientPath.blame.format(project=project_id, path=encoded_path)
  70. params = {"ref": file.ref, "range[start]": file.lineno, "range[end]": file.lineno}
  71. cache_key = client.get_cache_key(request_path, json.dumps(params))
  72. response = client.check_cache(cache_key)
  73. if response:
  74. metrics.incr("integrations.gitlab.get_blame_for_files.got_cached")
  75. logger.info(
  76. "sentry.integrations.gitlab.get_blame_for_files.got_cached",
  77. extra=extra,
  78. )
  79. else:
  80. response = client.get(
  81. request_path,
  82. params=params,
  83. )
  84. client.set_cache(cache_key, response, 60)
  85. if not isinstance(response, SequenceApiResponse):
  86. raise ApiError("Response is not in expected format")
  87. rate_limit_info = get_rate_limit_info_from_response(response)
  88. return _get_commit_info_from_blame_response(response, extra=extra), rate_limit_info
  89. def _create_file_blame_info(commit: CommitInfo, file: SourceLineInfo) -> FileBlameInfo:
  90. return FileBlameInfo(
  91. **asdict(file),
  92. commit=commit,
  93. )
  94. def _handle_file_blame_error(error: ApiError, file: SourceLineInfo, extra: Mapping[str, Any]):
  95. if error.code == 429:
  96. metrics.incr("integrations.gitlab.get_blame_for_files.rate_limit")
  97. logger.exception( # noqa: LOG004 # this function is used in an exception handler
  98. "get_blame_for_files.api_error",
  99. extra={
  100. **extra,
  101. "repo_name": file.repo.name,
  102. "file_path": file.path,
  103. "branch_name": file.ref,
  104. "file_lineno": file.lineno,
  105. },
  106. )
  107. def _get_commit_info_from_blame_response(
  108. response: Optional[Sequence[GitLabFileBlameResponseItem]], extra: Mapping[str, Any]
  109. ) -> Optional[CommitInfo]:
  110. if response is None:
  111. return None
  112. commits = [_create_commit_from_blame(item.get("commit"), extra) for item in response]
  113. commits_with_required_info = [commit for commit in commits if commit is not None]
  114. if not commits_with_required_info:
  115. return None
  116. return max(commits_with_required_info, key=lambda commit: commit.committedDate)
  117. def _create_commit_from_blame(
  118. commit: Optional[GitLabCommitResponse], extra: Mapping[str, Any]
  119. ) -> Optional[CommitInfo]:
  120. if not commit:
  121. logger.warning("get_blame_for_files.no_commit_in_response", extra=extra)
  122. return None
  123. commit_id = commit.get("id")
  124. committed_date = commit.get("committed_date")
  125. if not commit_id:
  126. logger.warning(
  127. "get_blame_for_files.invalid_commit_response", extra={**extra, "missing_property": "id"}
  128. )
  129. return None
  130. if not committed_date:
  131. logger.warning(
  132. "get_blame_for_files.invalid_commit_response",
  133. extra={**extra, "commit_id": commit_id, "missing_property": "committed_date"},
  134. )
  135. return None
  136. try:
  137. return CommitInfo(
  138. commitId=commit_id,
  139. commitMessage=commit.get("message"),
  140. commitAuthorName=commit.get("author_name"),
  141. commitAuthorEmail=commit.get("author_email"),
  142. committedDate=parse_datetime(committed_date).replace(tzinfo=timezone.utc),
  143. )
  144. except Exception:
  145. logger.exception("get_blame_for_files.invalid_commit_response", extra=extra)
  146. return None