xai-review 0.26.0__py3-none-any.whl → 0.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of xai-review might be problematic. Click here for more details.

Files changed (164) hide show
  1. ai_review/cli/commands/run_inline_reply_review.py +7 -0
  2. ai_review/cli/commands/run_summary_reply_review.py +7 -0
  3. ai_review/cli/main.py +17 -0
  4. ai_review/clients/bitbucket/pr/client.py +45 -8
  5. ai_review/clients/bitbucket/pr/schema/comments.py +21 -2
  6. ai_review/clients/bitbucket/pr/schema/files.py +8 -3
  7. ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
  8. ai_review/clients/bitbucket/pr/schema/user.py +7 -0
  9. ai_review/clients/bitbucket/tools.py +6 -0
  10. ai_review/clients/github/pr/client.py +98 -13
  11. ai_review/clients/github/pr/schema/comments.py +23 -1
  12. ai_review/clients/github/pr/schema/files.py +2 -1
  13. ai_review/clients/github/pr/schema/pull_request.py +1 -4
  14. ai_review/clients/github/pr/schema/reviews.py +2 -1
  15. ai_review/clients/github/pr/schema/user.py +6 -0
  16. ai_review/clients/github/pr/types.py +11 -1
  17. ai_review/clients/github/tools.py +6 -0
  18. ai_review/clients/gitlab/mr/client.py +67 -7
  19. ai_review/clients/gitlab/mr/schema/changes.py +1 -5
  20. ai_review/clients/gitlab/mr/schema/discussions.py +19 -8
  21. ai_review/clients/gitlab/mr/schema/notes.py +5 -1
  22. ai_review/clients/gitlab/mr/schema/user.py +7 -0
  23. ai_review/clients/gitlab/mr/types.py +16 -7
  24. ai_review/clients/gitlab/tools.py +5 -0
  25. ai_review/libs/config/prompt.py +96 -64
  26. ai_review/libs/config/review.py +2 -0
  27. ai_review/libs/config/vcs/base.py +2 -0
  28. ai_review/libs/config/vcs/pagination.py +6 -0
  29. ai_review/libs/http/paginate.py +43 -0
  30. ai_review/libs/llm/output_json_parser.py +60 -0
  31. ai_review/prompts/default_inline_reply.md +10 -0
  32. ai_review/prompts/default_summary_reply.md +14 -0
  33. ai_review/prompts/default_system_inline_reply.md +31 -0
  34. ai_review/prompts/default_system_summary_reply.md +13 -0
  35. ai_review/services/artifacts/schema.py +2 -2
  36. ai_review/services/hook/constants.py +14 -0
  37. ai_review/services/hook/service.py +95 -4
  38. ai_review/services/hook/types.py +18 -2
  39. ai_review/services/prompt/adapter.py +1 -1
  40. ai_review/services/prompt/service.py +49 -3
  41. ai_review/services/prompt/tools.py +21 -0
  42. ai_review/services/prompt/types.py +23 -0
  43. ai_review/services/review/gateway/comment.py +45 -6
  44. ai_review/services/review/gateway/llm.py +2 -1
  45. ai_review/services/review/gateway/types.py +50 -0
  46. ai_review/services/review/internal/inline/service.py +40 -0
  47. ai_review/services/review/internal/inline/types.py +8 -0
  48. ai_review/services/review/internal/inline_reply/schema.py +23 -0
  49. ai_review/services/review/internal/inline_reply/service.py +20 -0
  50. ai_review/services/review/internal/inline_reply/types.py +8 -0
  51. ai_review/services/review/{policy → internal/policy}/service.py +2 -1
  52. ai_review/services/review/internal/policy/types.py +15 -0
  53. ai_review/services/review/{summary → internal/summary}/service.py +2 -2
  54. ai_review/services/review/{summary → internal/summary}/types.py +1 -1
  55. ai_review/services/review/internal/summary_reply/__init__.py +0 -0
  56. ai_review/services/review/internal/summary_reply/schema.py +8 -0
  57. ai_review/services/review/internal/summary_reply/service.py +15 -0
  58. ai_review/services/review/internal/summary_reply/types.py +8 -0
  59. ai_review/services/review/runner/__init__.py +0 -0
  60. ai_review/services/review/runner/context.py +72 -0
  61. ai_review/services/review/runner/inline.py +80 -0
  62. ai_review/services/review/runner/inline_reply.py +80 -0
  63. ai_review/services/review/runner/summary.py +71 -0
  64. ai_review/services/review/runner/summary_reply.py +79 -0
  65. ai_review/services/review/runner/types.py +6 -0
  66. ai_review/services/review/service.py +78 -110
  67. ai_review/services/vcs/bitbucket/adapter.py +24 -0
  68. ai_review/services/vcs/bitbucket/client.py +107 -42
  69. ai_review/services/vcs/github/adapter.py +35 -0
  70. ai_review/services/vcs/github/client.py +105 -44
  71. ai_review/services/vcs/gitlab/adapter.py +26 -0
  72. ai_review/services/vcs/gitlab/client.py +91 -38
  73. ai_review/services/vcs/types.py +34 -0
  74. ai_review/tests/fixtures/clients/bitbucket.py +2 -2
  75. ai_review/tests/fixtures/clients/github.py +35 -6
  76. ai_review/tests/fixtures/clients/gitlab.py +42 -3
  77. ai_review/tests/fixtures/libs/__init__.py +0 -0
  78. ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
  79. ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
  80. ai_review/tests/fixtures/services/hook.py +8 -0
  81. ai_review/tests/fixtures/services/llm.py +8 -5
  82. ai_review/tests/fixtures/services/prompt.py +70 -0
  83. ai_review/tests/fixtures/services/review/base.py +41 -0
  84. ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
  85. ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
  86. ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
  87. ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
  88. ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
  89. ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
  90. ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
  91. ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
  92. ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
  93. ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
  94. ai_review/tests/fixtures/services/review/runner/context.py +50 -0
  95. ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
  96. ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
  97. ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
  98. ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
  99. ai_review/tests/fixtures/services/vcs.py +23 -0
  100. ai_review/tests/suites/cli/__init__.py +0 -0
  101. ai_review/tests/suites/cli/test_main.py +54 -0
  102. ai_review/tests/suites/clients/bitbucket/__init__.py +0 -0
  103. ai_review/tests/suites/clients/bitbucket/test_client.py +14 -0
  104. ai_review/tests/suites/clients/bitbucket/test_tools.py +31 -0
  105. ai_review/tests/suites/clients/github/test_tools.py +31 -0
  106. ai_review/tests/suites/clients/gitlab/test_tools.py +26 -0
  107. ai_review/tests/suites/libs/config/test_prompt.py +108 -28
  108. ai_review/tests/suites/libs/http/__init__.py +0 -0
  109. ai_review/tests/suites/libs/http/test_paginate.py +95 -0
  110. ai_review/tests/suites/libs/llm/__init__.py +0 -0
  111. ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
  112. ai_review/tests/suites/services/hook/test_service.py +88 -4
  113. ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
  114. ai_review/tests/suites/services/prompt/test_service.py +102 -58
  115. ai_review/tests/suites/services/prompt/test_tools.py +86 -1
  116. ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
  117. ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
  118. ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
  119. ai_review/tests/suites/services/review/internal/__init__.py +0 -0
  120. ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
  121. ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
  122. ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
  123. ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
  124. ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
  125. ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
  126. ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
  127. ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
  128. ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
  129. ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
  130. ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
  131. ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
  132. ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
  133. ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
  134. ai_review/tests/suites/services/review/runner/__init__.py +0 -0
  135. ai_review/tests/suites/services/review/runner/test_context.py +89 -0
  136. ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
  137. ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
  138. ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
  139. ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
  140. ai_review/tests/suites/services/review/test_service.py +64 -97
  141. ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
  142. ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
  143. ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
  144. ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
  145. ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +105 -0
  146. ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
  147. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
  148. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +160 -75
  149. ai_review/services/review/inline/service.py +0 -54
  150. ai_review/services/review/inline/types.py +0 -11
  151. ai_review/tests/fixtures/services/review/summary.py +0 -19
  152. ai_review/tests/suites/services/review/inline/test_service.py +0 -107
  153. /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
  154. /ai_review/services/review/{policy → internal}/__init__.py +0 -0
  155. /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
  156. /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
  157. /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
  158. /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
  159. /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
  160. /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
  161. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
  162. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
  163. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
  164. {xai_review-0.26.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,23 @@
1
- from ai_review.libs.asynchronous.gather import bounded_gather
2
1
  from ai_review.libs.logger import get_logger
3
2
  from ai_review.services.artifacts.service import ArtifactsService
4
3
  from ai_review.services.cost.service import CostService
5
4
  from ai_review.services.diff.service import DiffService
6
5
  from ai_review.services.git.service import GitService
7
- from ai_review.services.hook import hook
8
6
  from ai_review.services.llm.factory import get_llm_client
9
- from ai_review.services.prompt.adapter import build_prompt_context_from_mr_info
10
7
  from ai_review.services.prompt.service import PromptService
11
8
  from ai_review.services.review.gateway.comment import ReviewCommentGateway
12
9
  from ai_review.services.review.gateway.llm import ReviewLLMGateway
13
- from ai_review.services.review.inline.service import InlineCommentService
14
- from ai_review.services.review.policy.service import ReviewPolicyService
15
- from ai_review.services.review.summary.service import SummaryCommentService
10
+ from ai_review.services.review.internal.inline.service import InlineCommentService
11
+ from ai_review.services.review.internal.inline_reply.service import InlineCommentReplyService
12
+ from ai_review.services.review.internal.policy.service import ReviewPolicyService
13
+ from ai_review.services.review.internal.summary.service import SummaryCommentService
14
+ from ai_review.services.review.internal.summary_reply.service import SummaryCommentReplyService
15
+ from ai_review.services.review.runner.context import ContextReviewRunner
16
+ from ai_review.services.review.runner.inline import InlineReviewRunner
17
+ from ai_review.services.review.runner.inline_reply import InlineReplyReviewRunner
18
+ from ai_review.services.review.runner.summary import SummaryReviewRunner
19
+ from ai_review.services.review.runner.summary_reply import SummaryReplyReviewRunner
16
20
  from ai_review.services.vcs.factory import get_vcs_client
17
- from ai_review.services.vcs.types import ReviewInfoSchema
18
21
 
19
22
  logger = get_logger("REVIEW_SERVICE")
20
23
 
@@ -27,125 +30,90 @@ class ReviewService:
27
30
  self.diff = DiffService()
28
31
  self.cost = CostService()
29
32
  self.prompt = PromptService()
30
- self.policy = ReviewPolicyService()
31
- self.inline = InlineCommentService()
32
- self.summary = SummaryCommentService()
33
33
  self.artifacts = ArtifactsService()
34
+ self.review_policy = ReviewPolicyService()
35
+ self.inline_comment = InlineCommentService()
36
+ self.summary_comment = SummaryCommentService()
37
+ self.inline_comment_reply = InlineCommentReplyService()
38
+ self.summary_comment_reply = SummaryCommentReplyService()
34
39
 
35
- self.llm_gateway = ReviewLLMGateway(
40
+ self.review_llm_gateway = ReviewLLMGateway(
36
41
  llm=self.llm,
37
42
  cost=self.cost,
38
43
  artifacts=self.artifacts
39
44
  )
40
- self.comment_gateway = ReviewCommentGateway(vcs=self.vcs)
45
+ self.review_comment_gateway = ReviewCommentGateway(vcs=self.vcs)
41
46
 
42
- async def process_file_inline(self, file: str, review_info: ReviewInfoSchema) -> None:
43
- raw_diff = self.git.get_diff_for_file(review_info.base_sha, review_info.head_sha, file)
44
- if not raw_diff.strip():
45
- logger.debug(f"No diff for {file}, skipping")
46
- return
47
-
48
- rendered_file = self.diff.render_file(
49
- file=file,
50
- base_sha=review_info.base_sha,
51
- head_sha=review_info.head_sha,
52
- raw_diff=raw_diff,
47
+ self.inline_review_runner = InlineReviewRunner(
48
+ vcs=self.vcs,
49
+ git=self.git,
50
+ diff=self.diff,
51
+ cost=self.cost,
52
+ prompt=self.prompt,
53
+ review_policy=self.review_policy,
54
+ inline_comment=self.inline_comment,
55
+ review_llm_gateway=self.review_llm_gateway,
56
+ review_comment_gateway=self.review_comment_gateway
53
57
  )
54
- prompt_context = build_prompt_context_from_mr_info(review_info)
55
- prompt = self.prompt.build_inline_request(rendered_file, prompt_context)
56
- prompt_system = self.prompt.build_system_inline_request(prompt_context)
57
- prompt_result = await self.llm_gateway.ask(prompt, prompt_system)
58
-
59
- comments = self.inline.parse_model_output(prompt_result).dedupe()
60
- comments.root = self.policy.apply_for_inline_comments(comments.root)
61
- if not comments.root:
62
- logger.info(f"No inline comments for file: {file}")
63
- return
64
-
65
- logger.info(f"Posting {len(comments.root)} inline comments to {file}")
66
- await self.comment_gateway.process_inline_comments(comments)
67
-
68
- async def run_inline_review(self) -> None:
69
- await hook.emit_inline_review_start()
70
- if await self.comment_gateway.has_existing_inline_comments():
71
- return
72
-
73
- review_info = await self.vcs.get_review_info()
74
- logger.info(f"Starting inline review: {len(review_info.changed_files)} files changed")
75
-
76
- changed_files = self.policy.apply_for_files(review_info.changed_files)
77
- await bounded_gather([
78
- self.process_file_inline(changed_file, review_info)
79
- for changed_file in changed_files
80
- ])
81
- await hook.emit_inline_review_complete(self.cost.aggregate())
82
-
83
- async def run_context_review(self) -> None:
84
- await hook.emit_context_review_start()
85
- if await self.comment_gateway.has_existing_inline_comments():
86
- return
87
-
88
- review_info = await self.vcs.get_review_info()
89
- changed_files = self.policy.apply_for_files(review_info.changed_files)
90
- if not changed_files:
91
- logger.info("No files to review for context review")
92
- return
93
-
94
- logger.info(f"Starting context inline review: {len(changed_files)} files changed")
95
-
96
- rendered_files = self.diff.render_files(
58
+ self.context_review_runner = ContextReviewRunner(
59
+ vcs=self.vcs,
97
60
  git=self.git,
98
- files=changed_files,
99
- base_sha=review_info.base_sha,
100
- head_sha=review_info.head_sha,
61
+ diff=self.diff,
62
+ cost=self.cost,
63
+ prompt=self.prompt,
64
+ review_policy=self.review_policy,
65
+ inline_comment=self.inline_comment,
66
+ review_llm_gateway=self.review_llm_gateway,
67
+ review_comment_gateway=self.review_comment_gateway
68
+ )
69
+ self.summary_review_runner = SummaryReviewRunner(
70
+ vcs=self.vcs,
71
+ git=self.git,
72
+ diff=self.diff,
73
+ cost=self.cost,
74
+ prompt=self.prompt,
75
+ review_policy=self.review_policy,
76
+ summary_comment=self.summary_comment,
77
+ review_llm_gateway=self.review_llm_gateway,
78
+ review_comment_gateway=self.review_comment_gateway
79
+ )
80
+ self.inline_reply_review_runner = InlineReplyReviewRunner(
81
+ vcs=self.vcs,
82
+ git=self.git,
83
+ diff=self.diff,
84
+ cost=self.cost,
85
+ prompt=self.prompt,
86
+ review_policy=self.review_policy,
87
+ review_llm_gateway=self.review_llm_gateway,
88
+ inline_comment_reply=self.inline_comment_reply,
89
+ review_comment_gateway=self.review_comment_gateway
90
+ )
91
+ self.summary_reply_review_runner = SummaryReplyReviewRunner(
92
+ vcs=self.vcs,
93
+ git=self.git,
94
+ diff=self.diff,
95
+ cost=self.cost,
96
+ prompt=self.prompt,
97
+ review_policy=self.review_policy,
98
+ review_llm_gateway=self.review_llm_gateway,
99
+ summary_comment_reply=self.summary_comment_reply,
100
+ review_comment_gateway=self.review_comment_gateway
101
101
  )
102
- prompt_context = build_prompt_context_from_mr_info(review_info)
103
- prompt = self.prompt.build_context_request(rendered_files, prompt_context)
104
- prompt_system = self.prompt.build_system_context_request(prompt_context)
105
- prompt_result = await self.llm_gateway.ask(prompt, prompt_system)
106
102
 
107
- comments = self.inline.parse_model_output(prompt_result).dedupe()
108
- comments.root = self.policy.apply_for_context_comments(comments.root)
109
- if not comments.root:
110
- logger.info("No inline comments from context review")
111
- return
103
+ async def run_inline_review(self) -> None:
104
+ await self.inline_review_runner.run()
112
105
 
113
- logger.info(f"Posting {len(comments.root)} inline comments (context review)")
114
- await self.comment_gateway.process_inline_comments(comments)
115
- await hook.emit_context_review_complete(self.cost.aggregate())
106
+ async def run_context_review(self) -> None:
107
+ await self.context_review_runner.run()
116
108
 
117
109
  async def run_summary_review(self) -> None:
118
- await hook.emit_summary_review_start()
119
- if await self.comment_gateway.has_existing_summary_comments():
120
- return
121
-
122
- review_info = await self.vcs.get_review_info()
123
- changed_files = self.policy.apply_for_files(review_info.changed_files)
124
- if not changed_files:
125
- logger.info("No files to review for summary")
126
- return
127
-
128
- logger.info(f"Starting summary review: {len(changed_files)} files changed")
129
-
130
- rendered_files = self.diff.render_files(
131
- git=self.git,
132
- files=changed_files,
133
- base_sha=review_info.base_sha,
134
- head_sha=review_info.head_sha,
135
- )
136
- prompt_context = build_prompt_context_from_mr_info(review_info)
137
- prompt = self.prompt.build_summary_request(rendered_files, prompt_context)
138
- prompt_system = self.prompt.build_system_summary_request(prompt_context)
139
- prompt_result = await self.llm_gateway.ask(prompt, prompt_system)
110
+ await self.summary_review_runner.run()
140
111
 
141
- summary = self.summary.parse_model_output(prompt_result)
142
- if not summary.text.strip():
143
- logger.warning("Summary LLM output was empty, skipping comment")
144
- return
112
+ async def run_inline_reply_review(self) -> None:
113
+ await self.inline_reply_review_runner.run()
145
114
 
146
- logger.info(f"Posting summary review comment ({len(summary.text)} chars)")
147
- await self.comment_gateway.process_summary_comment(summary)
148
- await hook.emit_summary_review_complete(self.cost.aggregate())
115
+ async def run_summary_reply_review(self) -> None:
116
+ await self.summary_reply_review_runner.run()
149
117
 
150
118
  def report_total_cost(self):
151
119
  total_report = self.cost.aggregate()
@@ -0,0 +1,24 @@
1
+ from ai_review.clients.bitbucket.pr.schema.comments import BitbucketPRCommentSchema
2
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
3
+
4
+
5
+ def get_review_comment_from_bitbucket_pr_comment(comment: BitbucketPRCommentSchema) -> ReviewCommentSchema:
6
+ parent_id = comment.parent.id if comment.parent else None
7
+ thread_id = parent_id or comment.id
8
+
9
+ user = comment.user
10
+ author = UserSchema(
11
+ id=user.uuid if user else None,
12
+ name=user.display_name if user else "",
13
+ username=user.nickname if user else "",
14
+ )
15
+
16
+ return ReviewCommentSchema(
17
+ id=comment.id,
18
+ body=comment.content.raw or "",
19
+ file=comment.inline.path if comment.inline else None,
20
+ line=comment.inline.to_line if comment.inline else None,
21
+ author=author,
22
+ parent_id=parent_id,
23
+ thread_id=thread_id,
24
+ )
@@ -1,17 +1,20 @@
1
+ from collections import defaultdict
2
+
1
3
  from ai_review.clients.bitbucket.client import get_bitbucket_http_client
2
4
  from ai_review.clients.bitbucket.pr.schema.comments import (
3
5
  BitbucketCommentInlineSchema,
4
6
  BitbucketCommentContentSchema,
5
- BitbucketCreatePRCommentRequestSchema,
7
+ BitbucketCreatePRCommentRequestSchema, BitbucketParentSchema,
6
8
  )
7
9
  from ai_review.config import settings
8
10
  from ai_review.libs.logger import get_logger
11
+ from ai_review.services.vcs.bitbucket.adapter import get_review_comment_from_bitbucket_pr_comment
9
12
  from ai_review.services.vcs.types import (
10
13
  VCSClientProtocol,
11
14
  UserSchema,
12
15
  BranchRefSchema,
13
16
  ReviewInfoSchema,
14
- ReviewCommentSchema,
17
+ ReviewCommentSchema, ReviewThreadSchema, ThreadKind,
15
18
  )
16
19
 
17
20
  logger = get_logger("BITBUCKET_VCS_CLIENT")
@@ -23,7 +26,9 @@ class BitbucketVCSClient(VCSClientProtocol):
23
26
  self.workspace = settings.vcs.pipeline.workspace
24
27
  self.repo_slug = settings.vcs.pipeline.repo_slug
25
28
  self.pull_request_id = settings.vcs.pipeline.pull_request_id
29
+ self.pull_request_ref = f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}"
26
30
 
31
+ # --- Review info ---
27
32
  async def get_review_info(self) -> ReviewInfoSchema:
28
33
  try:
29
34
  pr = await self.http_client.pr.get_pull_request(
@@ -37,7 +42,7 @@ class BitbucketVCSClient(VCSClientProtocol):
37
42
  pull_request_id=self.pull_request_id,
38
43
  )
39
44
 
40
- logger.info(f"Fetched PR info for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
45
+ logger.info(f"Fetched PR info for {self.pull_request_ref}")
41
46
 
42
47
  return ReviewInfoSchema(
43
48
  id=pr.id,
@@ -81,11 +86,10 @@ class BitbucketVCSClient(VCSClientProtocol):
81
86
  ],
82
87
  )
83
88
  except Exception as error:
84
- logger.exception(
85
- f"Failed to fetch PR info {self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
86
- )
89
+ logger.exception(f"Failed to fetch PR info {self.pull_request_ref}: {error}")
87
90
  return ReviewInfoSchema()
88
91
 
92
+ # --- Comments ---
89
93
  async def get_general_comments(self) -> list[ReviewCommentSchema]:
90
94
  try:
91
95
  response = await self.http_client.pr.get_comments(
@@ -93,18 +97,15 @@ class BitbucketVCSClient(VCSClientProtocol):
93
97
  repo_slug=self.repo_slug,
94
98
  pull_request_id=self.pull_request_id,
95
99
  )
96
- logger.info(f"Fetched general comments for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
100
+ logger.info(f"Fetched general comments for {self.pull_request_ref}")
97
101
 
98
102
  return [
99
- ReviewCommentSchema(id=comment.id, body=comment.content.raw)
103
+ get_review_comment_from_bitbucket_pr_comment(comment)
100
104
  for comment in response.values
101
105
  if comment.inline is None
102
106
  ]
103
107
  except Exception as error:
104
- logger.exception(
105
- f"Failed to fetch general comments for "
106
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
107
- )
108
+ logger.exception(f"Failed to fetch general comments for {self.pull_request_ref}: {error}")
108
109
  return []
109
110
 
110
111
  async def get_inline_comments(self) -> list[ReviewCommentSchema]:
@@ -114,30 +115,20 @@ class BitbucketVCSClient(VCSClientProtocol):
114
115
  repo_slug=self.repo_slug,
115
116
  pull_request_id=self.pull_request_id,
116
117
  )
117
- logger.info(f"Fetched inline comments for {self.workspace}/{self.repo_slug}#{self.pull_request_id}")
118
+ logger.info(f"Fetched inline comments for {self.pull_request_ref}")
118
119
 
119
120
  return [
120
- ReviewCommentSchema(
121
- id=comment.id,
122
- body=comment.content.raw,
123
- file=comment.inline.path,
124
- line=comment.inline.to_line,
125
- )
121
+ get_review_comment_from_bitbucket_pr_comment(comment)
126
122
  for comment in response.values
127
123
  if comment.inline is not None
128
124
  ]
129
125
  except Exception as error:
130
- logger.exception(
131
- f"Failed to fetch inline comments for "
132
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
133
- )
126
+ logger.exception(f"Failed to fetch inline comments for {self.pull_request_ref}: {error}")
134
127
  return []
135
128
 
136
129
  async def create_general_comment(self, message: str) -> None:
137
130
  try:
138
- logger.info(
139
- f"Posting general comment to PR {self.workspace}/{self.repo_slug}#{self.pull_request_id}: {message}"
140
- )
131
+ logger.info(f"Posting general comment to PR {self.pull_request_ref}: {message}")
141
132
  request = BitbucketCreatePRCommentRequestSchema(
142
133
  content=BitbucketCommentContentSchema(raw=message)
143
134
  )
@@ -147,22 +138,14 @@ class BitbucketVCSClient(VCSClientProtocol):
147
138
  pull_request_id=self.pull_request_id,
148
139
  request=request,
149
140
  )
150
- logger.info(
151
- f"Created general comment in PR {self.workspace}/{self.repo_slug}#{self.pull_request_id}"
152
- )
141
+ logger.info(f"Created general comment in PR {self.pull_request_ref}")
153
142
  except Exception as error:
154
- logger.exception(
155
- f"Failed to create general comment in PR "
156
- f"{self.workspace}/{self.repo_slug}#{self.pull_request_id}: {error}"
157
- )
143
+ logger.exception(f"Failed to create general comment in PR {self.pull_request_ref}: {error}")
158
144
  raise
159
145
 
160
146
  async def create_inline_comment(self, file: str, line: int, message: str) -> None:
161
147
  try:
162
- logger.info(
163
- f"Posting inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
164
- f"at {file}:{line}: {message}"
165
- )
148
+ logger.info(f"Posting inline comment in {self.pull_request_ref} at {file}:{line}: {message}")
166
149
  request = BitbucketCreatePRCommentRequestSchema(
167
150
  content=BitbucketCommentContentSchema(raw=message),
168
151
  inline=BitbucketCommentInlineSchema(path=file, to_line=line),
@@ -173,13 +156,95 @@ class BitbucketVCSClient(VCSClientProtocol):
173
156
  pull_request_id=self.pull_request_id,
174
157
  request=request,
175
158
  )
176
- logger.info(
177
- f"Created inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
178
- f"at {file}:{line}"
159
+ logger.info(f"Created inline comment in {self.pull_request_ref} at {file}:{line}")
160
+ except Exception as error:
161
+ logger.exception(f"Failed to create inline comment in {self.pull_request_ref} at {file}:{line}: {error}")
162
+ raise
163
+
164
+ # --- Replies ---
165
+ async def create_inline_reply(self, thread_id: int | str, message: str) -> None:
166
+ try:
167
+ logger.info(f"Replying to inline thread {thread_id=} in PR {self.pull_request_ref}")
168
+ request = BitbucketCreatePRCommentRequestSchema(
169
+ parent=BitbucketParentSchema(id=int(thread_id)),
170
+ content=BitbucketCommentContentSchema(raw=message),
171
+ )
172
+ await self.http_client.pr.create_comment(
173
+ workspace=self.workspace,
174
+ repo_slug=self.repo_slug,
175
+ pull_request_id=self.pull_request_id,
176
+ request=request,
177
+ )
178
+ logger.info(f"Created inline reply to thread {thread_id=} in PR {self.pull_request_ref}")
179
+ except Exception as error:
180
+ logger.exception(
181
+ f"Failed to create inline reply to thread {thread_id=} in PR {self.pull_request_ref}: {error}"
182
+ )
183
+ raise
184
+
185
+ async def create_summary_reply(self, thread_id: int | str, message: str) -> None:
186
+ try:
187
+ logger.info(f"Replying to summary thread {thread_id=} in PR {self.pull_request_ref}")
188
+ request = BitbucketCreatePRCommentRequestSchema(
189
+ content=BitbucketCommentContentSchema(raw=message),
190
+ parent=BitbucketParentSchema(id=int(thread_id)),
191
+ )
192
+ await self.http_client.pr.create_comment(
193
+ workspace=self.workspace,
194
+ repo_slug=self.repo_slug,
195
+ pull_request_id=self.pull_request_id,
196
+ request=request,
179
197
  )
198
+ logger.info(f"Created summary reply to thread {thread_id=} in PR {self.pull_request_ref}")
180
199
  except Exception as error:
181
200
  logger.exception(
182
- f"Failed to create inline comment in {self.workspace}/{self.repo_slug}#{self.pull_request_id} "
183
- f"at {file}:{line}: {error}"
201
+ f"Failed to create summary reply to thread {thread_id=} in PR {self.pull_request_ref}: {error}"
184
202
  )
185
203
  raise
204
+
205
+ # --- Threads ---
206
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
207
+ try:
208
+ comments = await self.get_inline_comments()
209
+
210
+ threads: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
211
+ for comment in comments:
212
+ threads[comment.thread_id].append(comment)
213
+
214
+ logger.info(f"Built {len(threads)} inline threads for {self.pull_request_ref}")
215
+
216
+ return [
217
+ ReviewThreadSchema(
218
+ id=thread_id,
219
+ kind=ThreadKind.INLINE,
220
+ file=thread[0].file,
221
+ line=thread[0].line,
222
+ comments=sorted(thread, key=lambda c: int(c.id)),
223
+ )
224
+ for thread_id, thread in threads.items()
225
+ ]
226
+ except Exception as error:
227
+ logger.exception(f"Failed to fetch inline threads for {self.pull_request_ref}: {error}")
228
+ return []
229
+
230
+ async def get_general_threads(self) -> list[ReviewThreadSchema]:
231
+ try:
232
+ comments = await self.get_general_comments()
233
+
234
+ threads: dict[str | int, list[ReviewCommentSchema]] = defaultdict(list)
235
+ for comment in comments:
236
+ threads[comment.thread_id].append(comment)
237
+
238
+ logger.info(f"Built {len(threads)} general threads for {self.pull_request_ref}")
239
+
240
+ return [
241
+ ReviewThreadSchema(
242
+ id=thread_id,
243
+ kind=ThreadKind.SUMMARY,
244
+ comments=sorted(thread, key=lambda c: int(c.id)),
245
+ )
246
+ for thread_id, thread in threads.items()
247
+ ]
248
+ except Exception as error:
249
+ logger.exception(f"Failed to fetch general threads for {self.pull_request_ref}: {error}")
250
+ return []
@@ -0,0 +1,35 @@
1
+ from ai_review.clients.github.pr.schema.comments import GitHubPRCommentSchema, GitHubIssueCommentSchema
2
+ from ai_review.clients.github.pr.schema.user import GitHubUserSchema
3
+ from ai_review.services.vcs.types import ReviewCommentSchema, UserSchema
4
+
5
+
6
+ def get_user_from_github_user(user: GitHubUserSchema | None) -> UserSchema:
7
+ return UserSchema(
8
+ id=user.id if user else None,
9
+ name=user.login if user else "",
10
+ username=user.login if user else "",
11
+ )
12
+
13
+
14
+ def get_review_comment_from_github_pr_comment(comment: GitHubPRCommentSchema) -> ReviewCommentSchema:
15
+ parent_id = comment.in_reply_to_id
16
+ thread_id = parent_id or comment.id
17
+
18
+ return ReviewCommentSchema(
19
+ id=comment.id,
20
+ body=comment.body or "",
21
+ file=comment.path,
22
+ line=comment.line,
23
+ author=get_user_from_github_user(comment.user),
24
+ parent_id=parent_id,
25
+ thread_id=thread_id,
26
+ )
27
+
28
+
29
+ def get_review_comment_from_github_issue_comment(comment: GitHubIssueCommentSchema) -> ReviewCommentSchema:
30
+ return ReviewCommentSchema(
31
+ id=comment.id,
32
+ body=comment.body or "",
33
+ author=get_user_from_github_user(comment.user),
34
+ thread_id=comment.id
35
+ )