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.
- ai_review/__init__.py +0 -0
- ai_review/cli/__init__.py +0 -0
- ai_review/cli/commands/__init__.py +0 -0
- ai_review/cli/commands/run_context_review.py +7 -0
- ai_review/cli/commands/run_inline_review.py +7 -0
- ai_review/cli/commands/run_review.py +8 -0
- ai_review/cli/commands/run_summary_review.py +7 -0
- ai_review/cli/main.py +54 -0
- ai_review/clients/__init__.py +0 -0
- ai_review/clients/claude/__init__.py +0 -0
- ai_review/clients/claude/client.py +44 -0
- ai_review/clients/claude/schema.py +44 -0
- ai_review/clients/gemini/__init__.py +0 -0
- ai_review/clients/gemini/client.py +45 -0
- ai_review/clients/gemini/schema.py +78 -0
- ai_review/clients/gitlab/__init__.py +0 -0
- ai_review/clients/gitlab/client.py +31 -0
- ai_review/clients/gitlab/mr/__init__.py +0 -0
- ai_review/clients/gitlab/mr/client.py +101 -0
- ai_review/clients/gitlab/mr/schema/__init__.py +0 -0
- ai_review/clients/gitlab/mr/schema/changes.py +35 -0
- ai_review/clients/gitlab/mr/schema/comments.py +19 -0
- ai_review/clients/gitlab/mr/schema/discussions.py +34 -0
- ai_review/clients/openai/__init__.py +0 -0
- ai_review/clients/openai/client.py +42 -0
- ai_review/clients/openai/schema.py +37 -0
- ai_review/config.py +62 -0
- ai_review/libs/__init__.py +0 -0
- ai_review/libs/asynchronous/__init__.py +0 -0
- ai_review/libs/asynchronous/gather.py +14 -0
- ai_review/libs/config/__init__.py +0 -0
- ai_review/libs/config/artifacts.py +12 -0
- ai_review/libs/config/base.py +24 -0
- ai_review/libs/config/claude.py +13 -0
- ai_review/libs/config/gemini.py +13 -0
- ai_review/libs/config/gitlab.py +12 -0
- ai_review/libs/config/http.py +19 -0
- ai_review/libs/config/llm.py +61 -0
- ai_review/libs/config/logger.py +17 -0
- ai_review/libs/config/openai.py +13 -0
- ai_review/libs/config/prompt.py +121 -0
- ai_review/libs/config/review.py +30 -0
- ai_review/libs/config/vcs.py +19 -0
- ai_review/libs/constants/__init__.py +0 -0
- ai_review/libs/constants/llm_provider.py +7 -0
- ai_review/libs/constants/vcs_provider.py +6 -0
- ai_review/libs/diff/__init__.py +0 -0
- ai_review/libs/diff/models.py +100 -0
- ai_review/libs/diff/parser.py +111 -0
- ai_review/libs/diff/tools.py +24 -0
- ai_review/libs/http/__init__.py +0 -0
- ai_review/libs/http/client.py +14 -0
- ai_review/libs/http/event_hooks/__init__.py +0 -0
- ai_review/libs/http/event_hooks/base.py +13 -0
- ai_review/libs/http/event_hooks/logger.py +17 -0
- ai_review/libs/http/handlers.py +34 -0
- ai_review/libs/http/transports/__init__.py +0 -0
- ai_review/libs/http/transports/retry.py +34 -0
- ai_review/libs/logger.py +19 -0
- ai_review/libs/resources.py +24 -0
- ai_review/prompts/__init__.py +0 -0
- ai_review/prompts/default_context.md +14 -0
- ai_review/prompts/default_inline.md +8 -0
- ai_review/prompts/default_summary.md +3 -0
- ai_review/prompts/default_system_context.md +27 -0
- ai_review/prompts/default_system_inline.md +25 -0
- ai_review/prompts/default_system_summary.md +7 -0
- ai_review/resources/__init__.py +0 -0
- ai_review/resources/pricing.yaml +55 -0
- ai_review/services/__init__.py +0 -0
- ai_review/services/artifacts/__init__.py +0 -0
- ai_review/services/artifacts/schema.py +11 -0
- ai_review/services/artifacts/service.py +47 -0
- ai_review/services/artifacts/tools.py +8 -0
- ai_review/services/cost/__init__.py +0 -0
- ai_review/services/cost/schema.py +44 -0
- ai_review/services/cost/service.py +58 -0
- ai_review/services/diff/__init__.py +0 -0
- ai_review/services/diff/renderers.py +149 -0
- ai_review/services/diff/schema.py +6 -0
- ai_review/services/diff/service.py +96 -0
- ai_review/services/diff/tools.py +59 -0
- ai_review/services/git/__init__.py +0 -0
- ai_review/services/git/service.py +35 -0
- ai_review/services/git/types.py +11 -0
- ai_review/services/llm/__init__.py +0 -0
- ai_review/services/llm/claude/__init__.py +0 -0
- ai_review/services/llm/claude/client.py +26 -0
- ai_review/services/llm/factory.py +18 -0
- ai_review/services/llm/gemini/__init__.py +0 -0
- ai_review/services/llm/gemini/client.py +31 -0
- ai_review/services/llm/openai/__init__.py +0 -0
- ai_review/services/llm/openai/client.py +28 -0
- ai_review/services/llm/types.py +15 -0
- ai_review/services/prompt/__init__.py +0 -0
- ai_review/services/prompt/adapter.py +25 -0
- ai_review/services/prompt/schema.py +71 -0
- ai_review/services/prompt/service.py +56 -0
- ai_review/services/review/__init__.py +0 -0
- ai_review/services/review/inline/__init__.py +0 -0
- ai_review/services/review/inline/schema.py +53 -0
- ai_review/services/review/inline/service.py +38 -0
- ai_review/services/review/policy/__init__.py +0 -0
- ai_review/services/review/policy/service.py +60 -0
- ai_review/services/review/service.py +207 -0
- ai_review/services/review/summary/__init__.py +0 -0
- ai_review/services/review/summary/schema.py +15 -0
- ai_review/services/review/summary/service.py +14 -0
- ai_review/services/vcs/__init__.py +0 -0
- ai_review/services/vcs/factory.py +12 -0
- ai_review/services/vcs/gitlab/__init__.py +0 -0
- ai_review/services/vcs/gitlab/client.py +152 -0
- ai_review/services/vcs/types.py +55 -0
- ai_review/tests/__init__.py +0 -0
- ai_review/tests/fixtures/__init__.py +0 -0
- ai_review/tests/fixtures/git.py +31 -0
- ai_review/tests/suites/__init__.py +0 -0
- ai_review/tests/suites/clients/__init__.py +0 -0
- ai_review/tests/suites/clients/claude/__init__.py +0 -0
- ai_review/tests/suites/clients/claude/test_client.py +31 -0
- ai_review/tests/suites/clients/claude/test_schema.py +59 -0
- ai_review/tests/suites/clients/gemini/__init__.py +0 -0
- ai_review/tests/suites/clients/gemini/test_client.py +30 -0
- ai_review/tests/suites/clients/gemini/test_schema.py +105 -0
- ai_review/tests/suites/clients/openai/__init__.py +0 -0
- ai_review/tests/suites/clients/openai/test_client.py +30 -0
- ai_review/tests/suites/clients/openai/test_schema.py +53 -0
- ai_review/tests/suites/libs/__init__.py +0 -0
- ai_review/tests/suites/libs/diff/__init__.py +0 -0
- ai_review/tests/suites/libs/diff/test_models.py +105 -0
- ai_review/tests/suites/libs/diff/test_parser.py +115 -0
- ai_review/tests/suites/libs/diff/test_tools.py +62 -0
- ai_review/tests/suites/services/__init__.py +0 -0
- ai_review/tests/suites/services/diff/__init__.py +0 -0
- ai_review/tests/suites/services/diff/test_renderers.py +168 -0
- ai_review/tests/suites/services/diff/test_service.py +84 -0
- ai_review/tests/suites/services/diff/test_tools.py +108 -0
- ai_review/tests/suites/services/prompt/__init__.py +0 -0
- ai_review/tests/suites/services/prompt/test_schema.py +38 -0
- ai_review/tests/suites/services/prompt/test_service.py +128 -0
- ai_review/tests/suites/services/review/__init__.py +0 -0
- ai_review/tests/suites/services/review/inline/__init__.py +0 -0
- ai_review/tests/suites/services/review/inline/test_schema.py +65 -0
- ai_review/tests/suites/services/review/inline/test_service.py +49 -0
- ai_review/tests/suites/services/review/policy/__init__.py +0 -0
- ai_review/tests/suites/services/review/policy/test_service.py +95 -0
- ai_review/tests/suites/services/review/summary/__init__.py +0 -0
- ai_review/tests/suites/services/review/summary/test_schema.py +22 -0
- ai_review/tests/suites/services/review/summary/test_service.py +16 -0
- xai_review-0.3.0.dist-info/METADATA +11 -0
- xai_review-0.3.0.dist-info/RECORD +154 -0
- xai_review-0.3.0.dist-info/WHEEL +5 -0
- xai_review-0.3.0.dist-info/entry_points.txt +2 -0
- 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,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
|