xai-review 0.27.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 (147) 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/schema/comments.py +14 -0
  5. ai_review/clients/bitbucket/pr/schema/pull_request.py +1 -5
  6. ai_review/clients/bitbucket/pr/schema/user.py +7 -0
  7. ai_review/clients/github/pr/client.py +35 -4
  8. ai_review/clients/github/pr/schema/comments.py +21 -0
  9. ai_review/clients/github/pr/schema/pull_request.py +1 -4
  10. ai_review/clients/github/pr/schema/user.py +6 -0
  11. ai_review/clients/github/pr/types.py +11 -1
  12. ai_review/clients/gitlab/mr/client.py +32 -1
  13. ai_review/clients/gitlab/mr/schema/changes.py +1 -5
  14. ai_review/clients/gitlab/mr/schema/discussions.py +17 -7
  15. ai_review/clients/gitlab/mr/schema/notes.py +3 -0
  16. ai_review/clients/gitlab/mr/schema/user.py +7 -0
  17. ai_review/clients/gitlab/mr/types.py +16 -7
  18. ai_review/libs/config/prompt.py +96 -64
  19. ai_review/libs/config/review.py +2 -0
  20. ai_review/libs/llm/output_json_parser.py +60 -0
  21. ai_review/prompts/default_inline_reply.md +10 -0
  22. ai_review/prompts/default_summary_reply.md +14 -0
  23. ai_review/prompts/default_system_inline_reply.md +31 -0
  24. ai_review/prompts/default_system_summary_reply.md +13 -0
  25. ai_review/services/artifacts/schema.py +2 -2
  26. ai_review/services/hook/constants.py +14 -0
  27. ai_review/services/hook/service.py +95 -4
  28. ai_review/services/hook/types.py +18 -2
  29. ai_review/services/prompt/adapter.py +1 -1
  30. ai_review/services/prompt/service.py +49 -3
  31. ai_review/services/prompt/tools.py +21 -0
  32. ai_review/services/prompt/types.py +23 -0
  33. ai_review/services/review/gateway/comment.py +45 -6
  34. ai_review/services/review/gateway/llm.py +2 -1
  35. ai_review/services/review/gateway/types.py +50 -0
  36. ai_review/services/review/internal/inline/service.py +40 -0
  37. ai_review/services/review/internal/inline/types.py +8 -0
  38. ai_review/services/review/internal/inline_reply/schema.py +23 -0
  39. ai_review/services/review/internal/inline_reply/service.py +20 -0
  40. ai_review/services/review/internal/inline_reply/types.py +8 -0
  41. ai_review/services/review/{policy → internal/policy}/service.py +2 -1
  42. ai_review/services/review/internal/policy/types.py +15 -0
  43. ai_review/services/review/{summary → internal/summary}/service.py +2 -2
  44. ai_review/services/review/{summary → internal/summary}/types.py +1 -1
  45. ai_review/services/review/internal/summary_reply/__init__.py +0 -0
  46. ai_review/services/review/internal/summary_reply/schema.py +8 -0
  47. ai_review/services/review/internal/summary_reply/service.py +15 -0
  48. ai_review/services/review/internal/summary_reply/types.py +8 -0
  49. ai_review/services/review/runner/__init__.py +0 -0
  50. ai_review/services/review/runner/context.py +72 -0
  51. ai_review/services/review/runner/inline.py +80 -0
  52. ai_review/services/review/runner/inline_reply.py +80 -0
  53. ai_review/services/review/runner/summary.py +71 -0
  54. ai_review/services/review/runner/summary_reply.py +79 -0
  55. ai_review/services/review/runner/types.py +6 -0
  56. ai_review/services/review/service.py +78 -110
  57. ai_review/services/vcs/bitbucket/adapter.py +24 -0
  58. ai_review/services/vcs/bitbucket/client.py +107 -42
  59. ai_review/services/vcs/github/adapter.py +35 -0
  60. ai_review/services/vcs/github/client.py +105 -44
  61. ai_review/services/vcs/gitlab/adapter.py +26 -0
  62. ai_review/services/vcs/gitlab/client.py +91 -38
  63. ai_review/services/vcs/types.py +34 -0
  64. ai_review/tests/fixtures/clients/bitbucket.py +2 -2
  65. ai_review/tests/fixtures/clients/github.py +35 -6
  66. ai_review/tests/fixtures/clients/gitlab.py +42 -3
  67. ai_review/tests/fixtures/libs/__init__.py +0 -0
  68. ai_review/tests/fixtures/libs/llm/__init__.py +0 -0
  69. ai_review/tests/fixtures/libs/llm/output_json_parser.py +13 -0
  70. ai_review/tests/fixtures/services/hook.py +8 -0
  71. ai_review/tests/fixtures/services/llm.py +8 -5
  72. ai_review/tests/fixtures/services/prompt.py +70 -0
  73. ai_review/tests/fixtures/services/review/base.py +41 -0
  74. ai_review/tests/fixtures/services/review/gateway/__init__.py +0 -0
  75. ai_review/tests/fixtures/services/review/gateway/comment.py +98 -0
  76. ai_review/tests/fixtures/services/review/gateway/llm.py +17 -0
  77. ai_review/tests/fixtures/services/review/internal/__init__.py +0 -0
  78. ai_review/tests/fixtures/services/review/{inline.py → internal/inline.py} +8 -6
  79. ai_review/tests/fixtures/services/review/internal/inline_reply.py +25 -0
  80. ai_review/tests/fixtures/services/review/internal/policy.py +28 -0
  81. ai_review/tests/fixtures/services/review/internal/summary.py +21 -0
  82. ai_review/tests/fixtures/services/review/internal/summary_reply.py +19 -0
  83. ai_review/tests/fixtures/services/review/runner/__init__.py +0 -0
  84. ai_review/tests/fixtures/services/review/runner/context.py +50 -0
  85. ai_review/tests/fixtures/services/review/runner/inline.py +50 -0
  86. ai_review/tests/fixtures/services/review/runner/inline_reply.py +50 -0
  87. ai_review/tests/fixtures/services/review/runner/summary.py +50 -0
  88. ai_review/tests/fixtures/services/review/runner/summary_reply.py +50 -0
  89. ai_review/tests/fixtures/services/vcs.py +23 -0
  90. ai_review/tests/suites/cli/__init__.py +0 -0
  91. ai_review/tests/suites/cli/test_main.py +54 -0
  92. ai_review/tests/suites/libs/config/test_prompt.py +108 -28
  93. ai_review/tests/suites/libs/llm/__init__.py +0 -0
  94. ai_review/tests/suites/libs/llm/test_output_json_parser.py +155 -0
  95. ai_review/tests/suites/services/hook/test_service.py +88 -4
  96. ai_review/tests/suites/services/prompt/test_adapter.py +3 -3
  97. ai_review/tests/suites/services/prompt/test_service.py +102 -58
  98. ai_review/tests/suites/services/prompt/test_tools.py +86 -1
  99. ai_review/tests/suites/services/review/gateway/__init__.py +0 -0
  100. ai_review/tests/suites/services/review/gateway/test_comment.py +253 -0
  101. ai_review/tests/suites/services/review/gateway/test_llm.py +82 -0
  102. ai_review/tests/suites/services/review/internal/__init__.py +0 -0
  103. ai_review/tests/suites/services/review/internal/inline/__init__.py +0 -0
  104. ai_review/tests/suites/services/review/{inline → internal/inline}/test_schema.py +1 -1
  105. ai_review/tests/suites/services/review/internal/inline/test_service.py +81 -0
  106. ai_review/tests/suites/services/review/internal/inline_reply/__init__.py +0 -0
  107. ai_review/tests/suites/services/review/internal/inline_reply/test_schema.py +57 -0
  108. ai_review/tests/suites/services/review/internal/inline_reply/test_service.py +72 -0
  109. ai_review/tests/suites/services/review/internal/policy/__init__.py +0 -0
  110. ai_review/tests/suites/services/review/{policy → internal/policy}/test_service.py +1 -1
  111. ai_review/tests/suites/services/review/internal/summary/__init__.py +0 -0
  112. ai_review/tests/suites/services/review/{summary → internal/summary}/test_schema.py +1 -1
  113. ai_review/tests/suites/services/review/{summary → internal/summary}/test_service.py +2 -2
  114. ai_review/tests/suites/services/review/internal/summary_reply/__init__.py +0 -0
  115. ai_review/tests/suites/services/review/internal/summary_reply/test_schema.py +19 -0
  116. ai_review/tests/suites/services/review/internal/summary_reply/test_service.py +21 -0
  117. ai_review/tests/suites/services/review/runner/__init__.py +0 -0
  118. ai_review/tests/suites/services/review/runner/test_context.py +89 -0
  119. ai_review/tests/suites/services/review/runner/test_inline.py +100 -0
  120. ai_review/tests/suites/services/review/runner/test_inline_reply.py +109 -0
  121. ai_review/tests/suites/services/review/runner/test_summary.py +87 -0
  122. ai_review/tests/suites/services/review/runner/test_summary_reply.py +97 -0
  123. ai_review/tests/suites/services/review/test_service.py +64 -97
  124. ai_review/tests/suites/services/vcs/bitbucket/test_adapter.py +109 -0
  125. ai_review/tests/suites/services/vcs/bitbucket/{test_service.py → test_client.py} +88 -1
  126. ai_review/tests/suites/services/vcs/github/test_adapter.py +162 -0
  127. ai_review/tests/suites/services/vcs/github/{test_service.py → test_client.py} +102 -2
  128. ai_review/tests/suites/services/vcs/gitlab/test_adapter.py +105 -0
  129. ai_review/tests/suites/services/vcs/gitlab/{test_service.py → test_client.py} +99 -1
  130. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/METADATA +8 -5
  131. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/RECORD +143 -70
  132. ai_review/services/review/inline/service.py +0 -54
  133. ai_review/services/review/inline/types.py +0 -11
  134. ai_review/tests/fixtures/services/review/summary.py +0 -19
  135. ai_review/tests/suites/services/review/inline/test_service.py +0 -107
  136. /ai_review/{services/review/inline → libs/llm}/__init__.py +0 -0
  137. /ai_review/services/review/{policy → internal}/__init__.py +0 -0
  138. /ai_review/services/review/{summary → internal/inline}/__init__.py +0 -0
  139. /ai_review/services/review/{inline → internal/inline}/schema.py +0 -0
  140. /ai_review/{tests/suites/services/review/inline → services/review/internal/inline_reply}/__init__.py +0 -0
  141. /ai_review/{tests/suites/services/review → services/review/internal}/policy/__init__.py +0 -0
  142. /ai_review/{tests/suites/services/review → services/review/internal}/summary/__init__.py +0 -0
  143. /ai_review/services/review/{summary → internal/summary}/schema.py +0 -0
  144. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/WHEEL +0 -0
  145. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/entry_points.txt +0 -0
  146. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/licenses/LICENSE +0 -0
  147. {xai_review-0.27.0.dist-info → xai_review-0.28.0.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@ import re
2
2
 
3
3
  from ai_review.libs.logger import get_logger
4
4
  from ai_review.services.diff.schema import DiffFileSchema
5
+ from ai_review.services.vcs.types import ReviewThreadSchema
5
6
 
6
7
  logger = get_logger("PROMPT_TOOLS")
7
8
 
@@ -10,6 +11,26 @@ def format_file(diff: DiffFileSchema) -> str:
10
11
  return f"# File: {diff.file}\n{diff.diff}\n"
11
12
 
12
13
 
14
+ def format_files(diffs: list[DiffFileSchema]) -> str:
15
+ return "\n\n".join(map(format_file, diffs))
16
+
17
+
18
+ def format_thread(thread: ReviewThreadSchema) -> str:
19
+ if not thread.comments:
20
+ return "No comments in thread."
21
+
22
+ lines: list[str] = []
23
+ for comment in thread.comments:
24
+ user = (comment.author.name or comment.author.username or "User").strip()
25
+ body = (comment.body or "").strip()
26
+ if not body:
27
+ continue
28
+
29
+ lines.append(f"- {user}: {body}")
30
+
31
+ return "\n\n".join(lines)
32
+
33
+
13
34
  def normalize_prompt(text: str) -> str:
14
35
  tails_stripped = [re.sub(r"[ \t]+$", "", line) for line in text.splitlines()]
15
36
  text = "\n".join(tails_stripped)
@@ -2,6 +2,7 @@ from typing import Protocol
2
2
 
3
3
  from ai_review.services.diff.schema import DiffFileSchema
4
4
  from ai_review.services.prompt.schema import PromptContextSchema
5
+ from ai_review.services.vcs.types import ReviewThreadSchema
5
6
 
6
7
 
7
8
  class PromptServiceProtocol(Protocol):
@@ -17,6 +18,22 @@ class PromptServiceProtocol(Protocol):
17
18
  def build_context_request(self, diffs: list[DiffFileSchema], context: PromptContextSchema) -> str:
18
19
  ...
19
20
 
21
+ def build_inline_reply_request(
22
+ self,
23
+ diff: DiffFileSchema,
24
+ thread: ReviewThreadSchema,
25
+ context: PromptContextSchema
26
+ ) -> str:
27
+ ...
28
+
29
+ def build_summary_reply_request(
30
+ self,
31
+ diffs: list[DiffFileSchema],
32
+ thread: ReviewThreadSchema,
33
+ context: PromptContextSchema
34
+ ) -> str:
35
+ ...
36
+
20
37
  def build_system_inline_request(self, context: PromptContextSchema) -> str:
21
38
  ...
22
39
 
@@ -25,3 +42,9 @@ class PromptServiceProtocol(Protocol):
25
42
 
26
43
  def build_system_summary_request(self, context: PromptContextSchema) -> str:
27
44
  ...
45
+
46
+ def build_system_inline_reply_request(self, context: PromptContextSchema) -> str:
47
+ ...
48
+
49
+ def build_system_summary_reply_request(self, context: PromptContextSchema) -> str:
50
+ ...
@@ -2,17 +2,38 @@ from ai_review.config import settings
2
2
  from ai_review.libs.asynchronous.gather import bounded_gather
3
3
  from ai_review.libs.logger import get_logger
4
4
  from ai_review.services.hook import hook
5
- from ai_review.services.review.inline.schema import InlineCommentListSchema, InlineCommentSchema
6
- from ai_review.services.review.summary.schema import SummaryCommentSchema
7
- from ai_review.services.vcs.types import VCSClientProtocol
5
+ from ai_review.services.review.gateway.types import ReviewCommentGatewayProtocol
6
+ from ai_review.services.review.internal.inline.schema import InlineCommentListSchema, InlineCommentSchema
7
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
8
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
9
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
10
+ from ai_review.services.vcs.types import VCSClientProtocol, ReviewThreadSchema
8
11
 
9
12
  logger = get_logger("REVIEW_COMMENT_GATEWAY")
10
13
 
11
14
 
12
- class ReviewCommentGateway:
15
+ class ReviewCommentGateway(ReviewCommentGatewayProtocol):
13
16
  def __init__(self, vcs: VCSClientProtocol):
14
17
  self.vcs = vcs
15
18
 
19
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
20
+ threads = await self.vcs.get_inline_threads()
21
+ inline_threads = [
22
+ thread for thread in threads
23
+ if any(settings.review.inline_reply_tag in comment.body for comment in thread.comments)
24
+ ]
25
+ logger.info(f"Detected {len(inline_threads)}/{len(threads)} AI inline threads")
26
+ return inline_threads
27
+
28
+ async def get_summary_threads(self) -> list[ReviewThreadSchema]:
29
+ threads = await self.vcs.get_general_threads()
30
+ summary_threads = [
31
+ thread for thread in threads
32
+ if any(settings.review.summary_reply_tag in comment.body for comment in thread.comments)
33
+ ]
34
+ logger.info(f"Detected {len(summary_threads)}/{len(threads)} AI summary threads")
35
+ return summary_threads
36
+
16
37
  async def has_existing_inline_comments(self) -> bool:
17
38
  comments = await self.vcs.get_inline_comments()
18
39
  has_comments = any(
@@ -34,7 +55,25 @@ class ReviewCommentGateway:
34
55
 
35
56
  return has_comments
36
57
 
37
- async def process_inline_comment(self, comment: InlineCommentSchema):
58
+ async def process_inline_reply(self, thread_id: str, reply: InlineCommentReplySchema) -> None:
59
+ try:
60
+ await hook.emit_inline_comment_reply_start(reply)
61
+ await self.vcs.create_inline_reply(thread_id, reply.body_with_tag)
62
+ await hook.emit_inline_comment_reply_complete(reply)
63
+ except Exception as error:
64
+ logger.exception(f"Failed to create inline reply for thread {thread_id}: {error}")
65
+ await hook.emit_inline_comment_reply_error(reply)
66
+
67
+ async def process_summary_reply(self, thread_id: str, reply: SummaryCommentReplySchema) -> None:
68
+ try:
69
+ await hook.emit_summary_comment_reply_start(reply)
70
+ await self.vcs.create_summary_reply(thread_id, reply.body_with_tag)
71
+ await hook.emit_summary_comment_reply_complete(reply)
72
+ except Exception as error:
73
+ logger.exception(f"Failed to create summary reply for thread {thread_id}: {error}")
74
+ await hook.emit_summary_comment_reply_error(reply)
75
+
76
+ async def process_inline_comment(self, comment: InlineCommentSchema) -> None:
38
77
  try:
39
78
  await hook.emit_inline_comment_start(comment)
40
79
  await self.vcs.create_inline_comment(
@@ -52,7 +91,7 @@ class ReviewCommentGateway:
52
91
  logger.warning(f"Falling back to general comment for {comment.file}:{comment.line}")
53
92
  await self.process_summary_comment(SummaryCommentSchema(text=comment.fallback_body))
54
93
 
55
- async def process_summary_comment(self, comment: SummaryCommentSchema):
94
+ async def process_summary_comment(self, comment: SummaryCommentSchema) -> None:
56
95
  try:
57
96
  await hook.emit_summary_comment_start(comment)
58
97
  await self.vcs.create_general_comment(comment.body_with_tag)
@@ -3,11 +3,12 @@ from ai_review.services.artifacts.types import ArtifactsServiceProtocol
3
3
  from ai_review.services.cost.types import CostServiceProtocol
4
4
  from ai_review.services.hook import hook
5
5
  from ai_review.services.llm.types import LLMClientProtocol
6
+ from ai_review.services.review.gateway.types import ReviewLLMGatewayProtocol
6
7
 
7
8
  logger = get_logger("REVIEW_LLM_GATEWAY")
8
9
 
9
10
 
10
- class ReviewLLMGateway:
11
+ class ReviewLLMGateway(ReviewLLMGatewayProtocol):
11
12
  def __init__(
12
13
  self,
13
14
  llm: LLMClientProtocol,
@@ -0,0 +1,50 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.services.artifacts.types import ArtifactsServiceProtocol
4
+ from ai_review.services.cost.types import CostServiceProtocol
5
+ from ai_review.services.llm.types import LLMClientProtocol
6
+ from ai_review.services.review.internal.inline.schema import InlineCommentSchema, InlineCommentListSchema
7
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
8
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
9
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
10
+ from ai_review.services.vcs.types import VCSClientProtocol, ReviewThreadSchema
11
+
12
+
13
+ class ReviewLLMGatewayProtocol(Protocol):
14
+ llm: LLMClientProtocol
15
+ cost: CostServiceProtocol
16
+ artifacts: ArtifactsServiceProtocol
17
+
18
+ async def ask(self, prompt: str, prompt_system: str) -> str:
19
+ ...
20
+
21
+
22
+ class ReviewCommentGatewayProtocol(Protocol):
23
+ vcs: VCSClientProtocol
24
+
25
+ async def get_inline_threads(self) -> list[ReviewThreadSchema]:
26
+ ...
27
+
28
+ async def get_summary_threads(self) -> list[ReviewThreadSchema]:
29
+ ...
30
+
31
+ async def has_existing_inline_comments(self) -> bool:
32
+ ...
33
+
34
+ async def has_existing_summary_comments(self) -> bool:
35
+ ...
36
+
37
+ async def process_inline_reply(self, thread_id: str, reply: InlineCommentReplySchema) -> None:
38
+ ...
39
+
40
+ async def process_summary_reply(self, thread_id: str, reply: SummaryCommentReplySchema) -> None:
41
+ ...
42
+
43
+ async def process_inline_comment(self, comment: InlineCommentSchema) -> None:
44
+ ...
45
+
46
+ async def process_summary_comment(self, comment: SummaryCommentSchema) -> None:
47
+ ...
48
+
49
+ async def process_inline_comments(self, comments: InlineCommentListSchema) -> None:
50
+ ...
@@ -0,0 +1,40 @@
1
+ import re
2
+
3
+ from ai_review.libs.llm.output_json_parser import LLMOutputJSONParser
4
+ from ai_review.libs.logger import get_logger
5
+ from ai_review.services.review.internal.inline.schema import InlineCommentListSchema
6
+ from ai_review.services.review.internal.inline.types import InlineCommentServiceProtocol
7
+
8
+ logger = get_logger("INLINE_COMMENT_SERVICE")
9
+
10
+ FIRST_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*]", re.MULTILINE)
11
+
12
+
13
+ class InlineCommentService(InlineCommentServiceProtocol):
14
+ def __init__(self):
15
+ self.parser = LLMOutputJSONParser(model=InlineCommentListSchema)
16
+
17
+ def parse_model_output(self, output: str) -> InlineCommentListSchema:
18
+ output = (output or "").strip()
19
+ if not output:
20
+ logger.warning("LLM returned empty string for inline review")
21
+ return InlineCommentListSchema(root=[])
22
+
23
+ if parsed := self.parser.parse_output(output):
24
+ return parsed
25
+
26
+ logger.warning("Failed to parse JSON, trying to extract first JSON array...")
27
+
28
+ if json_array_match := FIRST_JSON_ARRAY_RE.search(output):
29
+ extracted = json_array_match.group(0)
30
+ logger.debug(f"Extracted potential JSON array (len={len(extracted)})")
31
+
32
+ if parsed := self.parser.try_parse(extracted):
33
+ logger.info("Successfully parsed JSON after extracting array from output")
34
+ return parsed
35
+ else:
36
+ logger.error("Extracted JSON array is still invalid after sanitization")
37
+ else:
38
+ logger.error("No JSON array found in LLM output")
39
+
40
+ return InlineCommentListSchema(root=[])
@@ -0,0 +1,8 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.services.review.internal.inline.schema import InlineCommentListSchema
4
+
5
+
6
+ class InlineCommentServiceProtocol(Protocol):
7
+ def parse_model_output(self, output: str) -> InlineCommentListSchema:
8
+ ...
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+
3
+ from ai_review.config import settings
4
+
5
+
6
+ class InlineCommentReplySchema(BaseModel):
7
+ message: str = Field(min_length=1)
8
+ suggestion: str | None = None
9
+
10
+ @field_validator("message")
11
+ def normalize_message(cls, value: str) -> str:
12
+ return value.strip()
13
+
14
+ @property
15
+ def body(self) -> str:
16
+ if self.suggestion:
17
+ return f"{self.message}\n\n```suggestion\n{self.suggestion}\n```"
18
+
19
+ return self.message
20
+
21
+ @property
22
+ def body_with_tag(self) -> str:
23
+ return f"{self.body}\n\n{settings.review.inline_reply_tag}"
@@ -0,0 +1,20 @@
1
+ from ai_review.libs.llm.output_json_parser import LLMOutputJSONParser
2
+ from ai_review.libs.logger import get_logger
3
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
4
+ from ai_review.services.review.internal.inline_reply.types import InlineCommentReplyServiceProtocol
5
+
6
+ logger = get_logger("INLINE_COMMENT_REPLY_SERVICE")
7
+
8
+
9
+ class InlineCommentReplyService(InlineCommentReplyServiceProtocol):
10
+ def __init__(self):
11
+ self.parser = LLMOutputJSONParser(model=InlineCommentReplySchema)
12
+
13
+ def parse_model_output(self, output: str) -> InlineCommentReplySchema | None:
14
+ logger.debug("Parsing LLM output for inline reply...")
15
+ parsed = self.parser.parse_output(output)
16
+ if parsed:
17
+ logger.debug("Inline reply parsed successfully")
18
+ else:
19
+ logger.warning("Inline reply parse failed or model returned empty JSON")
20
+ return parsed
@@ -0,0 +1,8 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.services.review.internal.inline_reply.schema import InlineCommentReplySchema
4
+
5
+
6
+ class InlineCommentReplyServiceProtocol(Protocol):
7
+ def parse_model_output(self, output: str) -> InlineCommentReplySchema | None:
8
+ ...
@@ -2,11 +2,12 @@ import fnmatch
2
2
 
3
3
  from ai_review.config import settings
4
4
  from ai_review.libs.logger import get_logger
5
+ from ai_review.services.review.internal.policy.types import ReviewPolicyServiceProtocol
5
6
 
6
7
  logger = get_logger("REVIEW_POLICY_SERVICE")
7
8
 
8
9
 
9
- class ReviewPolicyService:
10
+ class ReviewPolicyService(ReviewPolicyServiceProtocol):
10
11
  @classmethod
11
12
  def should_review_file(cls, file: str) -> bool:
12
13
  review = settings.review
@@ -0,0 +1,15 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class ReviewPolicyServiceProtocol(Protocol):
5
+ def should_review_file(self, file: str) -> bool:
6
+ ...
7
+
8
+ def apply_for_files(self, files: list[str]) -> list[str]:
9
+ ...
10
+
11
+ def apply_for_inline_comments(self, comments: list) -> list:
12
+ ...
13
+
14
+ def apply_for_context_comments(self, comments: list) -> list:
15
+ ...
@@ -1,6 +1,6 @@
1
1
  from ai_review.libs.logger import get_logger
2
- from ai_review.services.review.summary.schema import SummaryCommentSchema
3
- from ai_review.services.review.summary.types import SummaryCommentServiceProtocol
2
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
3
+ from ai_review.services.review.internal.summary.types import SummaryCommentServiceProtocol
4
4
 
5
5
  logger = get_logger("SUMMARY_COMMENT_SERVICE")
6
6
 
@@ -1,6 +1,6 @@
1
1
  from typing import Protocol
2
2
 
3
- from ai_review.services.review.summary.schema import SummaryCommentSchema
3
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
4
4
 
5
5
 
6
6
  class SummaryCommentServiceProtocol(Protocol):
@@ -0,0 +1,8 @@
1
+ from ai_review.config import settings
2
+ from ai_review.services.review.internal.summary.schema import SummaryCommentSchema
3
+
4
+
5
+ class SummaryCommentReplySchema(SummaryCommentSchema):
6
+ @property
7
+ def body_with_tag(self):
8
+ return f"{self.text}\n\n{settings.review.summary_reply_tag}"
@@ -0,0 +1,15 @@
1
+ from ai_review.libs.logger import get_logger
2
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
3
+ from ai_review.services.review.internal.summary_reply.types import SummaryCommentReplyServiceProtocol
4
+
5
+ logger = get_logger("SUMMARY_COMMENT_REPLY_SERVICE")
6
+
7
+
8
+ class SummaryCommentReplyService(SummaryCommentReplyServiceProtocol):
9
+ @classmethod
10
+ def parse_model_output(cls, output: str) -> SummaryCommentReplySchema:
11
+ text = (output or "").strip()
12
+ if not text:
13
+ logger.warning("LLM returned empty summary")
14
+
15
+ return SummaryCommentReplySchema(text=text)
@@ -0,0 +1,8 @@
1
+ from typing import Protocol
2
+
3
+ from ai_review.services.review.internal.summary_reply.schema import SummaryCommentReplySchema
4
+
5
+
6
+ class SummaryCommentReplyServiceProtocol(Protocol):
7
+ def parse_model_output(self, output: str) -> SummaryCommentReplySchema:
8
+ ...
File without changes
@@ -0,0 +1,72 @@
1
+ from ai_review.libs.logger import get_logger
2
+ from ai_review.services.cost.types import CostServiceProtocol
3
+ from ai_review.services.diff.types import DiffServiceProtocol
4
+ from ai_review.services.git.types import GitServiceProtocol
5
+ from ai_review.services.hook import hook
6
+ from ai_review.services.prompt.adapter import build_prompt_context_from_review_info
7
+ from ai_review.services.prompt.types import PromptServiceProtocol
8
+ from ai_review.services.review.gateway.types import ReviewLLMGatewayProtocol, ReviewCommentGatewayProtocol
9
+ from ai_review.services.review.internal.inline.types import InlineCommentServiceProtocol
10
+ from ai_review.services.review.internal.policy.types import ReviewPolicyServiceProtocol
11
+ from ai_review.services.review.runner.types import ReviewRunnerProtocol
12
+ from ai_review.services.vcs.types import VCSClientProtocol
13
+
14
+ logger = get_logger("CONTEXT_REVIEW_RUNNER")
15
+
16
+
17
+ class ContextReviewRunner(ReviewRunnerProtocol):
18
+ def __init__(
19
+ self,
20
+ vcs: VCSClientProtocol,
21
+ git: GitServiceProtocol,
22
+ diff: DiffServiceProtocol,
23
+ cost: CostServiceProtocol,
24
+ prompt: PromptServiceProtocol,
25
+ review_policy: ReviewPolicyServiceProtocol,
26
+ inline_comment: InlineCommentServiceProtocol,
27
+ review_llm_gateway: ReviewLLMGatewayProtocol,
28
+ review_comment_gateway: ReviewCommentGatewayProtocol,
29
+ ):
30
+ self.vcs = vcs
31
+ self.git = git
32
+ self.diff = diff
33
+ self.cost = cost
34
+ self.prompt = prompt
35
+ self.review_policy = review_policy
36
+ self.inline_comment = inline_comment
37
+ self.review_llm_gateway = review_llm_gateway
38
+ self.review_comment_gateway = review_comment_gateway
39
+
40
+ async def run(self) -> None:
41
+ await hook.emit_context_review_start()
42
+ if await self.review_comment_gateway.has_existing_inline_comments():
43
+ return
44
+
45
+ review_info = await self.vcs.get_review_info()
46
+ changed_files = self.review_policy.apply_for_files(review_info.changed_files)
47
+ if not changed_files:
48
+ logger.info("No files to review for context review")
49
+ return
50
+
51
+ logger.info(f"Starting context inline review: {len(changed_files)} files changed")
52
+
53
+ rendered_files = self.diff.render_files(
54
+ git=self.git,
55
+ files=changed_files,
56
+ base_sha=review_info.base_sha,
57
+ head_sha=review_info.head_sha,
58
+ )
59
+ prompt_context = build_prompt_context_from_review_info(review_info)
60
+ prompt = self.prompt.build_context_request(rendered_files, prompt_context)
61
+ prompt_system = self.prompt.build_system_context_request(prompt_context)
62
+ prompt_result = await self.review_llm_gateway.ask(prompt, prompt_system)
63
+
64
+ comments = self.inline_comment.parse_model_output(prompt_result).dedupe()
65
+ comments.root = self.review_policy.apply_for_context_comments(comments.root)
66
+ if not comments.root:
67
+ logger.info("No inline comments from context review")
68
+ return
69
+
70
+ logger.info(f"Posting {len(comments.root)} inline comments (context review)")
71
+ await self.review_comment_gateway.process_inline_comments(comments)
72
+ await hook.emit_context_review_complete(self.cost.aggregate())
@@ -0,0 +1,80 @@
1
+ from ai_review.libs.asynchronous.gather import bounded_gather
2
+ from ai_review.libs.logger import get_logger
3
+ from ai_review.services.cost.types import CostServiceProtocol
4
+ from ai_review.services.diff.types import DiffServiceProtocol
5
+ from ai_review.services.git.types import GitServiceProtocol
6
+ from ai_review.services.hook import hook
7
+ from ai_review.services.prompt.adapter import build_prompt_context_from_review_info
8
+ from ai_review.services.prompt.types import PromptServiceProtocol
9
+ from ai_review.services.review.gateway.types import ReviewLLMGatewayProtocol, ReviewCommentGatewayProtocol
10
+ from ai_review.services.review.internal.inline.types import InlineCommentServiceProtocol
11
+ from ai_review.services.review.internal.policy.types import ReviewPolicyServiceProtocol
12
+ from ai_review.services.review.runner.types import ReviewRunnerProtocol
13
+ from ai_review.services.vcs.types import ReviewInfoSchema, VCSClientProtocol
14
+
15
+ logger = get_logger("INLINE_REVIEW_RUNNER")
16
+
17
+
18
+ class InlineReviewRunner(ReviewRunnerProtocol):
19
+ def __init__(
20
+ self,
21
+ vcs: VCSClientProtocol,
22
+ git: GitServiceProtocol,
23
+ diff: DiffServiceProtocol,
24
+ cost: CostServiceProtocol,
25
+ prompt: PromptServiceProtocol,
26
+ review_policy: ReviewPolicyServiceProtocol,
27
+ inline_comment: InlineCommentServiceProtocol,
28
+ review_llm_gateway: ReviewLLMGatewayProtocol,
29
+ review_comment_gateway: ReviewCommentGatewayProtocol,
30
+ ):
31
+ self.vcs = vcs
32
+ self.git = git
33
+ self.diff = diff
34
+ self.cost = cost
35
+ self.prompt = prompt
36
+ self.review_policy = review_policy
37
+ self.inline_comment = inline_comment
38
+ self.review_llm_gateway = review_llm_gateway
39
+ self.review_comment_gateway = review_comment_gateway
40
+
41
+ async def process_file(self, file: str, review_info: ReviewInfoSchema) -> None:
42
+ raw_diff = self.git.get_diff_for_file(review_info.base_sha, review_info.head_sha, file)
43
+ if not raw_diff.strip():
44
+ logger.debug(f"No diff for {file}, skipping")
45
+ return
46
+
47
+ rendered_file = self.diff.render_file(
48
+ file=file,
49
+ base_sha=review_info.base_sha,
50
+ head_sha=review_info.head_sha,
51
+ raw_diff=raw_diff,
52
+ )
53
+ prompt_context = build_prompt_context_from_review_info(review_info)
54
+ prompt = self.prompt.build_inline_request(rendered_file, prompt_context)
55
+ prompt_system = self.prompt.build_system_inline_request(prompt_context)
56
+ prompt_result = await self.review_llm_gateway.ask(prompt, prompt_system)
57
+
58
+ comments = self.inline_comment.parse_model_output(prompt_result).dedupe()
59
+ comments.root = self.review_policy.apply_for_inline_comments(comments.root)
60
+ if not comments.root:
61
+ logger.info(f"No inline comments for file: {file}")
62
+ return
63
+
64
+ logger.info(f"Posting {len(comments.root)} inline comments to {file}")
65
+ await self.review_comment_gateway.process_inline_comments(comments)
66
+
67
+ async def run(self) -> None:
68
+ await hook.emit_inline_review_start()
69
+ if await self.review_comment_gateway.has_existing_inline_comments():
70
+ return
71
+
72
+ review_info = await self.vcs.get_review_info()
73
+ logger.info(f"Starting inline review: {len(review_info.changed_files)} files changed")
74
+
75
+ changed_files = self.review_policy.apply_for_files(review_info.changed_files)
76
+ await bounded_gather([
77
+ self.process_file(changed_file, review_info)
78
+ for changed_file in changed_files
79
+ ])
80
+ await hook.emit_inline_review_complete(self.cost.aggregate())
@@ -0,0 +1,80 @@
1
+ from ai_review.libs.asynchronous.gather import bounded_gather
2
+ from ai_review.libs.logger import get_logger
3
+ from ai_review.services.cost.types import CostServiceProtocol
4
+ from ai_review.services.diff.types import DiffServiceProtocol
5
+ from ai_review.services.git.types import GitServiceProtocol
6
+ from ai_review.services.hook import hook
7
+ from ai_review.services.prompt.adapter import build_prompt_context_from_review_info
8
+ from ai_review.services.prompt.types import PromptServiceProtocol
9
+ from ai_review.services.review.gateway.types import ReviewCommentGatewayProtocol, ReviewLLMGatewayProtocol
10
+ from ai_review.services.review.internal.inline_reply.types import InlineCommentReplyServiceProtocol
11
+ from ai_review.services.review.internal.policy.types import ReviewPolicyServiceProtocol
12
+ from ai_review.services.review.runner.types import ReviewRunnerProtocol
13
+ from ai_review.services.vcs.types import ReviewInfoSchema, VCSClientProtocol, ReviewThreadSchema
14
+
15
+ logger = get_logger("INLINE_REPLY_REVIEW_RUNNER")
16
+
17
+
18
+ class InlineReplyReviewRunner(ReviewRunnerProtocol):
19
+ def __init__(
20
+ self,
21
+ vcs: VCSClientProtocol,
22
+ git: GitServiceProtocol,
23
+ diff: DiffServiceProtocol,
24
+ cost: CostServiceProtocol,
25
+ prompt: PromptServiceProtocol,
26
+ review_policy: ReviewPolicyServiceProtocol,
27
+ review_llm_gateway: ReviewLLMGatewayProtocol,
28
+ inline_comment_reply: InlineCommentReplyServiceProtocol,
29
+ review_comment_gateway: ReviewCommentGatewayProtocol,
30
+ ):
31
+ self.vcs = vcs
32
+ self.git = git
33
+ self.diff = diff
34
+ self.cost = cost
35
+ self.prompt = prompt
36
+ self.review_policy = review_policy
37
+ self.review_llm_gateway = review_llm_gateway
38
+ self.inline_comment_reply = inline_comment_reply
39
+ self.review_comment_gateway = review_comment_gateway
40
+
41
+ async def process_thread_reply(self, thread: ReviewThreadSchema, review_info: ReviewInfoSchema):
42
+ logger.info(f"Processing inline reply for thread {thread.id}")
43
+
44
+ raw_diff = self.git.get_diff_for_file(review_info.base_sha, review_info.head_sha, thread.file)
45
+ if not raw_diff.strip():
46
+ logger.debug(f"No diff for {thread.file}, skipping")
47
+ return
48
+
49
+ rendered_file = self.diff.render_file(
50
+ file=thread.file,
51
+ base_sha=review_info.base_sha,
52
+ head_sha=review_info.head_sha,
53
+ raw_diff=raw_diff
54
+ )
55
+
56
+ prompt_context = build_prompt_context_from_review_info(review_info)
57
+ prompt = self.prompt.build_inline_reply_request(rendered_file, thread, prompt_context)
58
+ prompt_system = self.prompt.build_system_inline_reply_request(prompt_context)
59
+ prompt_result = await self.review_llm_gateway.ask(prompt, prompt_system)
60
+
61
+ reply = self.inline_comment_reply.parse_model_output(prompt_result)
62
+ if not reply:
63
+ logger.info(f"AI model returned no valid reply for thread {thread.id} ({len(thread.comments)} comments)")
64
+ return
65
+
66
+ await self.review_comment_gateway.process_inline_reply(thread.id, reply)
67
+
68
+ async def run(self) -> None:
69
+ await hook.emit_inline_reply_review_start()
70
+
71
+ review_info = await self.vcs.get_review_info()
72
+ threads = await self.review_comment_gateway.get_inline_threads()
73
+ if not threads:
74
+ logger.info("No AI inline threads found, skipping reply mode")
75
+ return
76
+
77
+ logger.info(f"Found {len(threads)} AI inline threads for reply")
78
+
79
+ await bounded_gather([self.process_thread_reply(thread, review_info) for thread in threads])
80
+ await hook.emit_inline_reply_review_complete(self.cost.aggregate())