xai-review 0.3.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 (154) hide show
  1. ai_review/__init__.py +0 -0
  2. ai_review/cli/__init__.py +0 -0
  3. ai_review/cli/commands/__init__.py +0 -0
  4. ai_review/cli/commands/run_context_review.py +7 -0
  5. ai_review/cli/commands/run_inline_review.py +7 -0
  6. ai_review/cli/commands/run_review.py +8 -0
  7. ai_review/cli/commands/run_summary_review.py +7 -0
  8. ai_review/cli/main.py +54 -0
  9. ai_review/clients/__init__.py +0 -0
  10. ai_review/clients/claude/__init__.py +0 -0
  11. ai_review/clients/claude/client.py +44 -0
  12. ai_review/clients/claude/schema.py +44 -0
  13. ai_review/clients/gemini/__init__.py +0 -0
  14. ai_review/clients/gemini/client.py +45 -0
  15. ai_review/clients/gemini/schema.py +78 -0
  16. ai_review/clients/gitlab/__init__.py +0 -0
  17. ai_review/clients/gitlab/client.py +31 -0
  18. ai_review/clients/gitlab/mr/__init__.py +0 -0
  19. ai_review/clients/gitlab/mr/client.py +101 -0
  20. ai_review/clients/gitlab/mr/schema/__init__.py +0 -0
  21. ai_review/clients/gitlab/mr/schema/changes.py +35 -0
  22. ai_review/clients/gitlab/mr/schema/comments.py +19 -0
  23. ai_review/clients/gitlab/mr/schema/discussions.py +34 -0
  24. ai_review/clients/openai/__init__.py +0 -0
  25. ai_review/clients/openai/client.py +42 -0
  26. ai_review/clients/openai/schema.py +37 -0
  27. ai_review/config.py +62 -0
  28. ai_review/libs/__init__.py +0 -0
  29. ai_review/libs/asynchronous/__init__.py +0 -0
  30. ai_review/libs/asynchronous/gather.py +14 -0
  31. ai_review/libs/config/__init__.py +0 -0
  32. ai_review/libs/config/artifacts.py +12 -0
  33. ai_review/libs/config/base.py +24 -0
  34. ai_review/libs/config/claude.py +13 -0
  35. ai_review/libs/config/gemini.py +13 -0
  36. ai_review/libs/config/gitlab.py +12 -0
  37. ai_review/libs/config/http.py +19 -0
  38. ai_review/libs/config/llm.py +61 -0
  39. ai_review/libs/config/logger.py +17 -0
  40. ai_review/libs/config/openai.py +13 -0
  41. ai_review/libs/config/prompt.py +121 -0
  42. ai_review/libs/config/review.py +30 -0
  43. ai_review/libs/config/vcs.py +19 -0
  44. ai_review/libs/constants/__init__.py +0 -0
  45. ai_review/libs/constants/llm_provider.py +7 -0
  46. ai_review/libs/constants/vcs_provider.py +6 -0
  47. ai_review/libs/diff/__init__.py +0 -0
  48. ai_review/libs/diff/models.py +100 -0
  49. ai_review/libs/diff/parser.py +111 -0
  50. ai_review/libs/diff/tools.py +24 -0
  51. ai_review/libs/http/__init__.py +0 -0
  52. ai_review/libs/http/client.py +14 -0
  53. ai_review/libs/http/event_hooks/__init__.py +0 -0
  54. ai_review/libs/http/event_hooks/base.py +13 -0
  55. ai_review/libs/http/event_hooks/logger.py +17 -0
  56. ai_review/libs/http/handlers.py +34 -0
  57. ai_review/libs/http/transports/__init__.py +0 -0
  58. ai_review/libs/http/transports/retry.py +34 -0
  59. ai_review/libs/logger.py +19 -0
  60. ai_review/libs/resources.py +24 -0
  61. ai_review/prompts/__init__.py +0 -0
  62. ai_review/prompts/default_context.md +14 -0
  63. ai_review/prompts/default_inline.md +8 -0
  64. ai_review/prompts/default_summary.md +3 -0
  65. ai_review/prompts/default_system_context.md +27 -0
  66. ai_review/prompts/default_system_inline.md +25 -0
  67. ai_review/prompts/default_system_summary.md +7 -0
  68. ai_review/resources/__init__.py +0 -0
  69. ai_review/resources/pricing.yaml +55 -0
  70. ai_review/services/__init__.py +0 -0
  71. ai_review/services/artifacts/__init__.py +0 -0
  72. ai_review/services/artifacts/schema.py +11 -0
  73. ai_review/services/artifacts/service.py +47 -0
  74. ai_review/services/artifacts/tools.py +8 -0
  75. ai_review/services/cost/__init__.py +0 -0
  76. ai_review/services/cost/schema.py +44 -0
  77. ai_review/services/cost/service.py +58 -0
  78. ai_review/services/diff/__init__.py +0 -0
  79. ai_review/services/diff/renderers.py +149 -0
  80. ai_review/services/diff/schema.py +6 -0
  81. ai_review/services/diff/service.py +96 -0
  82. ai_review/services/diff/tools.py +59 -0
  83. ai_review/services/git/__init__.py +0 -0
  84. ai_review/services/git/service.py +35 -0
  85. ai_review/services/git/types.py +11 -0
  86. ai_review/services/llm/__init__.py +0 -0
  87. ai_review/services/llm/claude/__init__.py +0 -0
  88. ai_review/services/llm/claude/client.py +26 -0
  89. ai_review/services/llm/factory.py +18 -0
  90. ai_review/services/llm/gemini/__init__.py +0 -0
  91. ai_review/services/llm/gemini/client.py +31 -0
  92. ai_review/services/llm/openai/__init__.py +0 -0
  93. ai_review/services/llm/openai/client.py +28 -0
  94. ai_review/services/llm/types.py +15 -0
  95. ai_review/services/prompt/__init__.py +0 -0
  96. ai_review/services/prompt/adapter.py +25 -0
  97. ai_review/services/prompt/schema.py +71 -0
  98. ai_review/services/prompt/service.py +56 -0
  99. ai_review/services/review/__init__.py +0 -0
  100. ai_review/services/review/inline/__init__.py +0 -0
  101. ai_review/services/review/inline/schema.py +53 -0
  102. ai_review/services/review/inline/service.py +38 -0
  103. ai_review/services/review/policy/__init__.py +0 -0
  104. ai_review/services/review/policy/service.py +60 -0
  105. ai_review/services/review/service.py +207 -0
  106. ai_review/services/review/summary/__init__.py +0 -0
  107. ai_review/services/review/summary/schema.py +15 -0
  108. ai_review/services/review/summary/service.py +14 -0
  109. ai_review/services/vcs/__init__.py +0 -0
  110. ai_review/services/vcs/factory.py +12 -0
  111. ai_review/services/vcs/gitlab/__init__.py +0 -0
  112. ai_review/services/vcs/gitlab/client.py +152 -0
  113. ai_review/services/vcs/types.py +55 -0
  114. ai_review/tests/__init__.py +0 -0
  115. ai_review/tests/fixtures/__init__.py +0 -0
  116. ai_review/tests/fixtures/git.py +31 -0
  117. ai_review/tests/suites/__init__.py +0 -0
  118. ai_review/tests/suites/clients/__init__.py +0 -0
  119. ai_review/tests/suites/clients/claude/__init__.py +0 -0
  120. ai_review/tests/suites/clients/claude/test_client.py +31 -0
  121. ai_review/tests/suites/clients/claude/test_schema.py +59 -0
  122. ai_review/tests/suites/clients/gemini/__init__.py +0 -0
  123. ai_review/tests/suites/clients/gemini/test_client.py +30 -0
  124. ai_review/tests/suites/clients/gemini/test_schema.py +105 -0
  125. ai_review/tests/suites/clients/openai/__init__.py +0 -0
  126. ai_review/tests/suites/clients/openai/test_client.py +30 -0
  127. ai_review/tests/suites/clients/openai/test_schema.py +53 -0
  128. ai_review/tests/suites/libs/__init__.py +0 -0
  129. ai_review/tests/suites/libs/diff/__init__.py +0 -0
  130. ai_review/tests/suites/libs/diff/test_models.py +105 -0
  131. ai_review/tests/suites/libs/diff/test_parser.py +115 -0
  132. ai_review/tests/suites/libs/diff/test_tools.py +62 -0
  133. ai_review/tests/suites/services/__init__.py +0 -0
  134. ai_review/tests/suites/services/diff/__init__.py +0 -0
  135. ai_review/tests/suites/services/diff/test_renderers.py +168 -0
  136. ai_review/tests/suites/services/diff/test_service.py +84 -0
  137. ai_review/tests/suites/services/diff/test_tools.py +108 -0
  138. ai_review/tests/suites/services/prompt/__init__.py +0 -0
  139. ai_review/tests/suites/services/prompt/test_schema.py +38 -0
  140. ai_review/tests/suites/services/prompt/test_service.py +128 -0
  141. ai_review/tests/suites/services/review/__init__.py +0 -0
  142. ai_review/tests/suites/services/review/inline/__init__.py +0 -0
  143. ai_review/tests/suites/services/review/inline/test_schema.py +65 -0
  144. ai_review/tests/suites/services/review/inline/test_service.py +49 -0
  145. ai_review/tests/suites/services/review/policy/__init__.py +0 -0
  146. ai_review/tests/suites/services/review/policy/test_service.py +95 -0
  147. ai_review/tests/suites/services/review/summary/__init__.py +0 -0
  148. ai_review/tests/suites/services/review/summary/test_schema.py +22 -0
  149. ai_review/tests/suites/services/review/summary/test_service.py +16 -0
  150. xai_review-0.3.0.dist-info/METADATA +11 -0
  151. xai_review-0.3.0.dist-info/RECORD +154 -0
  152. xai_review-0.3.0.dist-info/WHEEL +5 -0
  153. xai_review-0.3.0.dist-info/entry_points.txt +2 -0
  154. xai_review-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,149 @@
1
+ """
2
+ Renderers for diff views.
3
+
4
+ Supported build modes:
5
+ - FULL_FILE_CURRENT snapshot after changes (+ markers for added)
6
+ - FULL_FILE_PREVIOUS snapshot before changes (+ markers for removed)
7
+ - FULL_FILE_DIFF full unified diff (+, -, unchanged)
8
+ - ONLY_ADDED only added lines
9
+ - ONLY_REMOVED only removed lines
10
+ - ADDED_AND_REMOVED added + removed
11
+ - ONLY_ADDED_WITH_CONTEXT added + surrounding unchanged lines
12
+ - ONLY_REMOVED_WITH_CONTEXT removed + surrounding unchanged lines
13
+ - ADDED_AND_REMOVED_WITH_CONTEXT added + removed + surrounding unchanged lines
14
+ """
15
+ from enum import Enum
16
+ from typing import Iterable, Optional
17
+
18
+ from ai_review.libs.diff.models import DiffFile, DiffLineType
19
+ from ai_review.services.diff.tools import normalize_file_path, marker_for_line, read_snapshot
20
+
21
+
22
+ class MarkerType(Enum):
23
+ ADDED = "added"
24
+ REMOVED = "removed"
25
+
26
+
27
+ def build_full_file_current(file: Optional[DiffFile], file_path: str, head_sha: str | None) -> str:
28
+ text = read_snapshot(file_path, head_sha=head_sha)
29
+ if text is None:
30
+ return f"# Failed to read current snapshot for {file_path}"
31
+
32
+ added_new = file.added_line_numbers() if file else set()
33
+ return render_plain_numbered(text.splitlines(), added_new, marker_type=MarkerType.ADDED)
34
+
35
+
36
+ def build_full_file_previous(file: Optional[DiffFile], file_path: str, base_sha: str | None) -> str:
37
+ text = read_snapshot(file_path, base_sha=base_sha)
38
+ if text is None:
39
+ return f"# Failed to read previous snapshot for {file_path} (base_sha missing or file absent)"
40
+
41
+ removed_old = file.removed_line_numbers() if file else set()
42
+ return render_plain_numbered(text.splitlines(), removed_old, marker_type=MarkerType.REMOVED)
43
+
44
+
45
+ def build_full_file_diff(file: DiffFile) -> str:
46
+ return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=0)
47
+
48
+
49
+ def build_only_added(file: DiffFile) -> str:
50
+ return render_unified(file, include_added=True, include_removed=False, include_unchanged=False, context=0)
51
+
52
+
53
+ def build_only_removed(file: DiffFile) -> str:
54
+ return render_unified(file, include_added=False, include_removed=True, include_unchanged=False, context=0)
55
+
56
+
57
+ def build_added_and_removed(file: DiffFile) -> str:
58
+ return render_unified(file, include_added=True, include_removed=True, include_unchanged=False, context=0)
59
+
60
+
61
+ def build_only_added_with_context(file: DiffFile, context: int) -> str:
62
+ return render_unified(file, include_added=True, include_removed=False, include_unchanged=True, context=context)
63
+
64
+
65
+ def build_only_removed_with_context(file: DiffFile, context: int) -> str:
66
+ return render_unified(file, include_added=False, include_removed=True, include_unchanged=True, context=context)
67
+
68
+
69
+ def build_added_and_removed_with_context(file: DiffFile, context: int) -> str:
70
+ return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=context)
71
+
72
+
73
+ def render_plain_numbered(lines: Iterable[str], changed: set[int], marker_type: MarkerType) -> str:
74
+ def choose_marker(line_no: int) -> str:
75
+ if line_no not in changed:
76
+ return ""
77
+ if marker_type is MarkerType.ADDED:
78
+ return marker_for_line(added=True)
79
+ if marker_type is MarkerType.REMOVED:
80
+ return marker_for_line(removed=True)
81
+ return ""
82
+
83
+ return "\n".join(
84
+ f"{line_no}: {content}{choose_marker(line_no)}"
85
+ for line_no, content in enumerate(lines, start=1)
86
+ )
87
+
88
+
89
+ def render_unified(
90
+ file: DiffFile,
91
+ *,
92
+ include_added: bool,
93
+ include_removed: bool,
94
+ include_unchanged: bool,
95
+ context: int,
96
+ ) -> str:
97
+ """
98
+ Render unified diff view.
99
+
100
+ Each line is prefixed with:
101
+ '+' for added lines,
102
+ '-' for removed lines,
103
+ ' ' for unchanged lines.
104
+
105
+ Context controls how many unchanged lines around modifications are shown.
106
+ """
107
+ lines_out: list[str] = []
108
+
109
+ added_new_positions = file.added_line_numbers()
110
+ removed_old_positions = file.removed_line_numbers()
111
+
112
+ def in_context(old_no: Optional[int], new_no: Optional[int]) -> bool:
113
+ """Check if an unchanged line falls within context radius."""
114
+ if context <= 0:
115
+ return False
116
+ if include_added and new_no is not None:
117
+ if any(abs(new_no - a) <= context for a in added_new_positions):
118
+ return True
119
+ if include_removed and old_no is not None:
120
+ if any(abs(old_no - r) <= context for r in removed_old_positions):
121
+ return True
122
+ return False
123
+
124
+ for hunk in file.hunks:
125
+ old_no = hunk.orig_range.start
126
+ new_no = hunk.new_range.start
127
+
128
+ for line in hunk.lines:
129
+ if line.type is DiffLineType.ADDED:
130
+ if include_added:
131
+ lines_out.append(f"+{new_no}: {line.content}{marker_for_line(DiffLineType.ADDED)}")
132
+ new_no += 1
133
+
134
+ elif line.type is DiffLineType.REMOVED:
135
+ if include_removed:
136
+ lines_out.append(f"-{old_no}: {line.content}{marker_for_line(DiffLineType.REMOVED)}")
137
+ old_no += 1
138
+
139
+ else:
140
+ if include_unchanged and (context == 0 or in_context(old_no, new_no)):
141
+ lines_out.append(f" {new_no}: {line.content}")
142
+ old_no += 1
143
+ new_no += 1
144
+
145
+ if not lines_out:
146
+ header = normalize_file_path(file.new_name or file.orig_name)
147
+ return f"# No matching lines for mode in {header}"
148
+
149
+ return "\n".join(lines_out)
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class DiffFileSchema(BaseModel):
5
+ file: str
6
+ diff: str
@@ -0,0 +1,96 @@
1
+ from ai_review.config import settings
2
+ from ai_review.libs.config.review import ReviewMode
3
+ from ai_review.libs.diff.models import Diff
4
+ from ai_review.libs.diff.parser import DiffParser
5
+ from ai_review.libs.logger import get_logger
6
+ from ai_review.services.diff.renderers import (
7
+ build_full_file_diff,
8
+ build_full_file_current,
9
+ build_full_file_previous,
10
+ build_only_added,
11
+ build_only_removed,
12
+ build_added_and_removed,
13
+ build_only_added_with_context,
14
+ build_only_removed_with_context,
15
+ build_added_and_removed_with_context
16
+ )
17
+ from ai_review.services.diff.schema import DiffFileSchema
18
+ from ai_review.services.diff.tools import find_diff_file
19
+ from ai_review.services.git.types import GitServiceProtocol
20
+
21
+ logger = get_logger("DIFF_SERVICE")
22
+
23
+
24
+ class DiffService:
25
+ @classmethod
26
+ def parse(cls, raw_diff: str) -> Diff:
27
+ if not raw_diff.strip():
28
+ logger.debug("Received empty diff string")
29
+ return Diff(files=[], raw=raw_diff)
30
+
31
+ try:
32
+ return DiffParser.parse(raw_diff)
33
+ except Exception as error:
34
+ logger.exception(f"Failed to parse diff: {error}")
35
+ raise
36
+
37
+ @classmethod
38
+ def render_file(
39
+ cls,
40
+ file: str,
41
+ raw_diff: str,
42
+ base_sha: str | None = None,
43
+ head_sha: str | None = None,
44
+ ) -> DiffFileSchema:
45
+ diff = cls.parse(raw_diff)
46
+ target = find_diff_file(diff, file)
47
+
48
+ match settings.review.mode:
49
+ case ReviewMode.FULL_FILE_CURRENT:
50
+ file_diff = build_full_file_current(target, file, head_sha)
51
+ case ReviewMode.FULL_FILE_PREVIOUS:
52
+ file_diff = build_full_file_previous(target, file, base_sha)
53
+ case ReviewMode.FULL_FILE_DIFF:
54
+ file_diff = build_full_file_diff(target)
55
+ case ReviewMode.ONLY_ADDED:
56
+ file_diff = build_only_added(target)
57
+ case ReviewMode.ONLY_REMOVED:
58
+ file_diff = build_only_removed(target)
59
+ case ReviewMode.ADDED_AND_REMOVED:
60
+ file_diff = build_added_and_removed(target)
61
+ case ReviewMode.ONLY_ADDED_WITH_CONTEXT:
62
+ file_diff = build_only_added_with_context(target, settings.review.context_lines)
63
+ case ReviewMode.ONLY_REMOVED_WITH_CONTEXT:
64
+ file_diff = build_only_removed_with_context(target, settings.review.context_lines)
65
+ case ReviewMode.ADDED_AND_REMOVED_WITH_CONTEXT:
66
+ file_diff = build_added_and_removed_with_context(target, settings.review.context_lines)
67
+ case _:
68
+ file_diff = f"# Unsupported mode: {settings.review.mode}"
69
+
70
+ return DiffFileSchema(diff=file_diff, file=file)
71
+
72
+ @classmethod
73
+ def render_files(
74
+ cls,
75
+ git: GitServiceProtocol,
76
+ files: list[str],
77
+ base_sha: str,
78
+ head_sha: str,
79
+ ) -> list[DiffFileSchema]:
80
+ annotated: list[DiffFileSchema] = []
81
+ for file in files:
82
+ raw_diff = git.get_diff_for_file(base_sha, head_sha, file)
83
+ if not raw_diff.strip():
84
+ logger.debug(f"No diff for {file}, skipping")
85
+ continue
86
+
87
+ annotated.append(
88
+ cls.render_file(
89
+ file=file,
90
+ base_sha=base_sha,
91
+ head_sha=head_sha,
92
+ raw_diff=raw_diff,
93
+ )
94
+ )
95
+
96
+ return annotated
@@ -0,0 +1,59 @@
1
+ from pathlib import Path
2
+
3
+ from ai_review.config import settings
4
+ from ai_review.libs.diff.models import Diff, DiffFile, DiffLineType
5
+ from ai_review.libs.logger import get_logger
6
+ from ai_review.services.git.service import GitService
7
+
8
+ logger = get_logger("DIFF_TOOLS")
9
+
10
+
11
+ def normalize_file_path(file_path: str) -> str:
12
+ """Normalize a git diff file path (remove a/ b/ prefixes, convert slashes)."""
13
+ if not file_path:
14
+ return ""
15
+
16
+ file_path = file_path.replace("\\", "/").lstrip("./")
17
+ if file_path.startswith("a/") or file_path.startswith("b/"):
18
+ return file_path[2:]
19
+
20
+ return file_path
21
+
22
+
23
+ def find_diff_file(diff: Diff, file_path: str) -> DiffFile | None:
24
+ target = normalize_file_path(file_path)
25
+ for file in diff.files:
26
+ if normalize_file_path(file.new_name) == target or normalize_file_path(file.orig_name) == target:
27
+ return file
28
+
29
+ return None
30
+
31
+
32
+ def read_snapshot(file_path: str, *, base_sha: str | None = None, head_sha: str | None = None) -> str | None:
33
+ git = GitService()
34
+ try:
35
+ if head_sha:
36
+ text = git.get_file_at_commit(file_path, head_sha)
37
+ if text is not None:
38
+ return text
39
+
40
+ if base_sha:
41
+ text = git.get_file_at_commit(file_path, base_sha)
42
+ if text is not None:
43
+ return text
44
+ except Exception as e:
45
+ logger.warning(f"Git snapshot read failed for {file_path}: {e}")
46
+
47
+ try:
48
+ return Path(file_path).read_text(encoding="utf-8")
49
+ except Exception as e:
50
+ logger.warning(f"Workspace read failed for {file_path}: {e}")
51
+ return None
52
+
53
+
54
+ def marker_for_line(line_type: DiffLineType | None = None, *, added: bool = False, removed: bool = False) -> str:
55
+ if (line_type is DiffLineType.ADDED) or added:
56
+ return settings.review.review_added_marker
57
+ if (line_type is DiffLineType.REMOVED) or removed:
58
+ return settings.review.review_removed_marker
59
+ return ""
File without changes
@@ -0,0 +1,35 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from ai_review.services.git.types import GitServiceProtocol
5
+
6
+
7
+ class GitService(GitServiceProtocol):
8
+ def __init__(self, repo_dir: str = "."):
9
+ self.repo_dir = Path(repo_dir)
10
+
11
+ def run_git(self, *args: str) -> str:
12
+ result = subprocess.run(
13
+ ["git", *args],
14
+ cwd=self.repo_dir,
15
+ capture_output=True,
16
+ text=True,
17
+ check=True,
18
+ )
19
+ return result.stdout
20
+
21
+ def get_diff(self, base_sha: str, head_sha: str, unified: int = 3) -> str:
22
+ return self.run_git("diff", f"--unified={unified}", base_sha, head_sha)
23
+
24
+ def get_diff_for_file(self, base_sha: str, head_sha: str, file: str, unified: int = 3) -> str:
25
+ return self.run_git("diff", f"--unified={unified}", base_sha, head_sha, "--", file)
26
+
27
+ def get_changed_files(self, base_sha: str, head_sha: str) -> list[str]:
28
+ output = self.run_git("diff", "--name-only", base_sha, head_sha)
29
+ return [line.strip() for line in output.splitlines() if line.strip()]
30
+
31
+ def get_file_at_commit(self, file_path: str, sha: str) -> str | None:
32
+ try:
33
+ return self.run_git("show", f"{sha}:{file_path}")
34
+ except subprocess.CalledProcessError:
35
+ return None
@@ -0,0 +1,11 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class GitServiceProtocol(Protocol):
5
+ def get_diff(self, base_sha: str, head_sha: str, unified: int = 3) -> str: ...
6
+
7
+ def get_diff_for_file(self, base_sha: str, head_sha: str, file: str, unified: int = 3) -> str: ...
8
+
9
+ def get_changed_files(self, base_sha: str, head_sha: str) -> list[str]: ...
10
+
11
+ def get_file_at_commit(self, file_path: str, sha: str) -> str | None: ...
File without changes
File without changes
@@ -0,0 +1,26 @@
1
+ from ai_review.clients.claude.client import get_claude_http_client
2
+ from ai_review.clients.claude.schema import ClaudeChatRequestSchema, ClaudeMessageSchema
3
+ from ai_review.config import settings
4
+ from ai_review.services.llm.types import LLMClient, ChatResultSchema
5
+
6
+
7
+ class ClaudeLLMClient(LLMClient):
8
+ def __init__(self):
9
+ self.http_client = get_claude_http_client()
10
+
11
+ async def chat(self, prompt: str, prompt_system: str) -> ChatResultSchema:
12
+ meta = settings.llm.meta
13
+ request = ClaudeChatRequestSchema(
14
+ model=meta.model,
15
+ system=prompt_system,
16
+ messages=[ClaudeMessageSchema(role="user", content=prompt)],
17
+ max_tokens=meta.max_tokens,
18
+ temperature=meta.temperature,
19
+ )
20
+ response = await self.http_client.chat(request)
21
+ return ChatResultSchema(
22
+ text=response.first_text,
23
+ total_tokens=response.usage.total_tokens,
24
+ prompt_tokens=response.usage.input_tokens,
25
+ completion_tokens=response.usage.output_tokens,
26
+ )
@@ -0,0 +1,18 @@
1
+ from ai_review.config import settings
2
+ from ai_review.libs.constants.llm_provider import LLMProvider
3
+ from ai_review.services.llm.claude.client import ClaudeLLMClient
4
+ from ai_review.services.llm.gemini.client import GeminiLLMClient
5
+ from ai_review.services.llm.openai.client import OpenAILLMClient
6
+ from ai_review.services.llm.types import LLMClient
7
+
8
+
9
+ def get_llm_client() -> LLMClient:
10
+ match settings.llm.provider:
11
+ case LLMProvider.OPENAI:
12
+ return OpenAILLMClient()
13
+ case LLMProvider.GEMINI:
14
+ return GeminiLLMClient()
15
+ case LLMProvider.CLAUDE:
16
+ return ClaudeLLMClient()
17
+ case _:
18
+ raise ValueError(f"Unsupported provider: {settings.llm.provider}")
File without changes
@@ -0,0 +1,31 @@
1
+ from ai_review.clients.gemini.client import get_gemini_http_client
2
+ from ai_review.clients.gemini.schema import (
3
+ GeminiPartSchema,
4
+ GeminiContentSchema,
5
+ GeminiChatRequestSchema,
6
+ GeminiGenerationConfigSchema,
7
+ )
8
+ from ai_review.config import settings
9
+ from ai_review.services.llm.types import LLMClient, ChatResultSchema
10
+
11
+
12
+ class GeminiLLMClient(LLMClient):
13
+ def __init__(self):
14
+ self.http_client = get_gemini_http_client()
15
+
16
+ async def chat(self, prompt: str, prompt_system: str) -> ChatResultSchema:
17
+ request = GeminiChatRequestSchema(
18
+ contents=[GeminiContentSchema(parts=[GeminiPartSchema(text=prompt)])],
19
+ generation_config=GeminiGenerationConfigSchema(
20
+ temperature=settings.llm.meta.temperature,
21
+ max_output_tokens=settings.llm.meta.max_tokens,
22
+ ),
23
+ system_instruction=GeminiContentSchema(parts=[GeminiPartSchema(text=prompt_system)])
24
+ )
25
+ response = await self.http_client.chat(request)
26
+ return ChatResultSchema(
27
+ text=response.first_text,
28
+ total_tokens=response.usage.total_tokens,
29
+ prompt_tokens=response.usage.prompt_tokens,
30
+ completion_tokens=response.usage.completion_tokens,
31
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ from ai_review.clients.openai.client import get_openai_http_client
2
+ from ai_review.clients.openai.schema import OpenAIChatRequestSchema, OpenAIMessageSchema
3
+ from ai_review.config import settings
4
+ from ai_review.services.llm.types import LLMClient, ChatResultSchema
5
+
6
+
7
+ class OpenAILLMClient(LLMClient):
8
+ def __init__(self):
9
+ self.http_client = get_openai_http_client()
10
+
11
+ async def chat(self, prompt: str, prompt_system: str) -> ChatResultSchema:
12
+ meta = settings.llm.meta
13
+ request = OpenAIChatRequestSchema(
14
+ model=meta.model,
15
+ messages=[
16
+ OpenAIMessageSchema(role="system", content=prompt_system),
17
+ OpenAIMessageSchema(role="user", content=prompt),
18
+ ],
19
+ max_tokens=meta.max_tokens,
20
+ temperature=meta.temperature,
21
+ )
22
+ response = await self.http_client.chat(request)
23
+ return ChatResultSchema(
24
+ text=response.first_text,
25
+ total_tokens=response.usage.total_tokens,
26
+ prompt_tokens=response.usage.prompt_tokens,
27
+ completion_tokens=response.usage.completion_tokens,
28
+ )
@@ -0,0 +1,15 @@
1
+ from typing import Protocol
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ChatResultSchema(BaseModel):
7
+ text: str
8
+ total_tokens: int | None = None
9
+ prompt_tokens: int | None = None
10
+ completion_tokens: int | None = None
11
+
12
+
13
+ class LLMClient(Protocol):
14
+ async def chat(self, prompt: str, prompt_system: str) -> ChatResultSchema:
15
+ ...
File without changes
@@ -0,0 +1,25 @@
1
+ from ai_review.services.prompt.schema import PromptContextSchema
2
+ from ai_review.services.vcs.types import MRInfoSchema
3
+
4
+
5
+ def build_prompt_context_from_mr_info(mr: MRInfoSchema) -> PromptContextSchema:
6
+ return PromptContextSchema(
7
+ merge_request_title=mr.title,
8
+ merge_request_description=mr.description,
9
+
10
+ merge_request_author_name=mr.author.name,
11
+ merge_request_author_username=mr.author.username,
12
+
13
+ merge_request_reviewers=[reviewer.name for reviewer in mr.reviewers],
14
+ merge_request_reviewers_usernames=[reviewer.username for reviewer in mr.reviewers],
15
+ merge_request_reviewer=mr.reviewers[0].name if mr.reviewers else None,
16
+
17
+ merge_request_assignees=[assignee.name for assignee in mr.assignees],
18
+ merge_request_assignees_usernames=[assignee.username for assignee in mr.assignees],
19
+
20
+ source_branch=mr.source_branch,
21
+ target_branch=mr.target_branch,
22
+
23
+ labels=mr.labels,
24
+ changed_files=mr.changed_files,
25
+ )
@@ -0,0 +1,71 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from ai_review.config import settings
4
+
5
+
6
+ class PromptContextSchema(BaseModel):
7
+ merge_request_title: str | None = None
8
+ merge_request_description: str | None = None
9
+
10
+ merge_request_author_name: str | None = None
11
+ merge_request_author_username: str | None = None
12
+
13
+ merge_request_reviewer: str | None = None
14
+ merge_request_reviewers: list[str] = Field(default_factory=list)
15
+ merge_request_reviewers_usernames: list[str] = Field(default_factory=list)
16
+
17
+ merge_request_assignees: list[str] = Field(default_factory=list)
18
+ merge_request_assignees_usernames: list[str] = Field(default_factory=list)
19
+
20
+ source_branch: str | None = None
21
+ target_branch: str | None = None
22
+
23
+ labels: list[str] = Field(default_factory=list)
24
+ changed_files: list[str] = Field(default_factory=list)
25
+
26
+ @property
27
+ def reviewers_format(self) -> str:
28
+ return ", ".join(self.merge_request_reviewers)
29
+
30
+ @property
31
+ def reviewers_usernames_format(self) -> str:
32
+ return ", ".join(self.merge_request_reviewers_usernames)
33
+
34
+ @property
35
+ def assignees_format(self) -> str:
36
+ return ", ".join(self.merge_request_assignees)
37
+
38
+ @property
39
+ def assignees_usernames_format(self) -> str:
40
+ return ", ".join(self.merge_request_assignees_usernames)
41
+
42
+ @property
43
+ def labels_format(self) -> str:
44
+ return ", ".join(self.labels)
45
+
46
+ @property
47
+ def changed_files_format(self) -> str:
48
+ return ", ".join(self.changed_files)
49
+
50
+ def apply_format(self, prompt: str) -> str:
51
+ return prompt.format(
52
+ merge_request_title=self.merge_request_title or "",
53
+ merge_request_description=self.merge_request_description or "",
54
+
55
+ merge_request_author_name=self.merge_request_author_name or "",
56
+ merge_request_author_username=self.merge_request_author_username or "",
57
+
58
+ merge_request_reviewer=self.merge_request_reviewer or "",
59
+ merge_request_reviewers=self.reviewers_format,
60
+ merge_request_reviewers_usernames=self.reviewers_usernames_format,
61
+
62
+ merge_request_assignees=self.assignees_format,
63
+ merge_request_assignees_usernames=self.assignees_usernames_format,
64
+
65
+ source_branch=self.source_branch or "",
66
+ target_branch=self.target_branch or "",
67
+
68
+ labels=self.labels_format,
69
+ changed_files=self.changed_files_format,
70
+ **settings.prompt.context
71
+ )
@@ -0,0 +1,56 @@
1
+ from ai_review.config import settings
2
+ from ai_review.services.diff.schema import DiffFileSchema
3
+ from ai_review.services.prompt.schema import PromptContextSchema
4
+
5
+
6
+ def format_file(diff: DiffFileSchema) -> str:
7
+ return f"# File: {diff.file}\n{diff.diff}\n"
8
+
9
+
10
+ class PromptService:
11
+ @classmethod
12
+ def build_inline_request(cls, diff: DiffFileSchema, context: PromptContextSchema) -> str:
13
+ inline_prompts = "\n\n".join(settings.prompt.load_inline())
14
+ prompt = (
15
+ f"{inline_prompts}\n\n"
16
+ f"## Diff\n\n"
17
+ f"{format_file(diff)}"
18
+ )
19
+ return context.apply_format(prompt)
20
+
21
+ @classmethod
22
+ def build_summary_request(cls, diffs: list[DiffFileSchema], context: PromptContextSchema) -> str:
23
+ changes = "\n\n".join(map(format_file, diffs))
24
+ summary_prompts = "\n\n".join(settings.prompt.load_summary())
25
+ prompt = (
26
+ f"{summary_prompts}\n\n"
27
+ f"## Changes\n\n"
28
+ f"{changes}\n"
29
+ )
30
+ return context.apply_format(prompt)
31
+
32
+ @classmethod
33
+ def build_context_request(cls, diffs: list[DiffFileSchema], context: PromptContextSchema) -> str:
34
+ changes = "\n\n".join(map(format_file, diffs))
35
+ inline_prompts = "\n\n".join(settings.prompt.load_context())
36
+ prompt = (
37
+ f"{inline_prompts}\n\n"
38
+ f"## Diff\n\n"
39
+ f"{changes}\n"
40
+ )
41
+ return context.apply_format(prompt)
42
+
43
+ @classmethod
44
+ def build_system_inline_request(cls, context: PromptContextSchema) -> str:
45
+ prompt = "\n\n".join(settings.prompt.load_system_inline())
46
+ return context.apply_format(prompt)
47
+
48
+ @classmethod
49
+ def build_system_context_request(cls, context: PromptContextSchema) -> str:
50
+ prompt = "\n\n".join(settings.prompt.load_system_context())
51
+ return context.apply_format(prompt)
52
+
53
+ @classmethod
54
+ def build_system_summary_request(cls, context: PromptContextSchema) -> str:
55
+ prompt = "\n\n".join(settings.prompt.load_system_summary())
56
+ return context.apply_format(prompt)
File without changes
File without changes