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,168 @@
1
+ # ai_review/tests/services/diff/test_renderers.py
2
+
3
+ import pytest
4
+
5
+ from ai_review.libs.diff.models import (
6
+ DiffFile,
7
+ DiffHunk,
8
+ DiffRange,
9
+ DiffLine,
10
+ DiffLineType,
11
+ FileMode,
12
+ )
13
+ from ai_review.services.diff import renderers, tools
14
+
15
+
16
+ # ---------- fixtures ----------
17
+
18
+ @pytest.fixture
19
+ def sample_diff_file() -> DiffFile:
20
+ """
21
+ Build a synthetic DiffFile with one hunk that simulates:
22
+
23
+ Original file:
24
+ 1: keep A (UNCHANGED)
25
+ 2: remove me (REMOVED)
26
+ 3: keep B (UNCHANGED)
27
+
28
+ New file:
29
+ 1: keep A (UNCHANGED)
30
+ 2: keep B (UNCHANGED)
31
+ 3: added me (ADDED)
32
+
33
+ The hunk.lines sequence (like in unified diff) is:
34
+ UNCHANGED("keep A"),
35
+ REMOVED("remove me"),
36
+ UNCHANGED("keep B"),
37
+ ADDED("added me")
38
+ """
39
+ # unified view for hunk.lines
40
+ line_u1 = DiffLine(DiffLineType.UNCHANGED, number=None, content="keep A", position=1)
41
+ line_r2 = DiffLine(DiffLineType.REMOVED, number=None, content="remove me", position=2)
42
+ line_u2 = DiffLine(DiffLineType.UNCHANGED, number=None, content="keep B", position=3)
43
+ line_a3 = DiffLine(DiffLineType.ADDED, number=None, content="added me", position=4)
44
+
45
+ # original and new ranges with numbering
46
+ orig_u1 = DiffLine(DiffLineType.UNCHANGED, number=1, content="keep A", position=1)
47
+ orig_r2 = DiffLine(DiffLineType.REMOVED, number=2, content="remove me", position=2)
48
+ orig_u3 = DiffLine(DiffLineType.UNCHANGED, number=3, content="keep B", position=3)
49
+
50
+ new_u1 = DiffLine(DiffLineType.UNCHANGED, number=1, content="keep A", position=1)
51
+ new_u2 = DiffLine(DiffLineType.UNCHANGED, number=2, content="keep B", position=2)
52
+ new_a3 = DiffLine(DiffLineType.ADDED, number=3, content="added me", position=3)
53
+
54
+ hunk = DiffHunk(
55
+ header="",
56
+ orig_range=DiffRange(start=1, length=3, lines=[orig_u1, orig_r2, orig_u3]),
57
+ new_range=DiffRange(start=1, length=3, lines=[new_u1, new_u2, new_a3]),
58
+ lines=[line_u1, line_r2, line_u2, line_a3],
59
+ )
60
+
61
+ return DiffFile(
62
+ header="diff --git a/x b/x",
63
+ mode=FileMode.MODIFIED,
64
+ orig_name="a/x",
65
+ new_name="b/x",
66
+ hunks=[hunk],
67
+ )
68
+
69
+
70
+ @pytest.fixture(autouse=True)
71
+ def patch_marker(monkeypatch: pytest.MonkeyPatch) -> None:
72
+ """Patch marker_for_line to use simple markers (# added / # removed)."""
73
+
74
+ def fake_marker(line_type=None, *, added: bool = False, removed: bool = False) -> str:
75
+ if added or line_type is DiffLineType.ADDED:
76
+ return " # added"
77
+ if removed or line_type is DiffLineType.REMOVED:
78
+ return " # removed"
79
+ return ""
80
+
81
+ monkeypatch.setattr(tools, "marker_for_line", fake_marker)
82
+
83
+
84
+ # ---------- tests: FULL FILE ----------
85
+
86
+ def test_build_full_file_current(monkeypatch: pytest.MonkeyPatch, sample_diff_file: DiffFile) -> None:
87
+ monkeypatch.setattr(
88
+ "ai_review.services.diff.renderers.read_snapshot",
89
+ lambda *_, **__: "keep A\nkeep B\nadded me",
90
+ )
91
+ out = renderers.build_full_file_current(sample_diff_file, "x", head_sha="HEAD")
92
+ assert out == "1: keep A\n2: keep B\n3: added me # added"
93
+
94
+
95
+ def test_build_full_file_previous(monkeypatch: pytest.MonkeyPatch, sample_diff_file: DiffFile) -> None:
96
+ monkeypatch.setattr(
97
+ "ai_review.services.diff.renderers.read_snapshot",
98
+ lambda *_, **__: "keep A\nremove me\nkeep B",
99
+ )
100
+ out = renderers.build_full_file_previous(sample_diff_file, "x", base_sha="BASE")
101
+ assert out == "1: keep A\n2: remove me # removed\n3: keep B"
102
+
103
+
104
+ def test_build_full_file_diff(sample_diff_file: DiffFile) -> None:
105
+ out = renderers.build_full_file_diff(sample_diff_file)
106
+ assert out == (
107
+ " 1: keep A\n"
108
+ "-2: remove me # removed\n"
109
+ " 2: keep B\n"
110
+ "+3: added me # added"
111
+ )
112
+
113
+
114
+ # ---------- tests: ONLY_* ----------
115
+
116
+ def test_build_only_added(sample_diff_file: DiffFile) -> None:
117
+ """
118
+ Should render only added lines.
119
+ """
120
+ out = renderers.build_only_added(sample_diff_file)
121
+ assert out == "+3: added me # added"
122
+
123
+
124
+ def test_build_only_removed(sample_diff_file: DiffFile) -> None:
125
+ """
126
+ Should render only removed lines.
127
+ """
128
+ out = renderers.build_only_removed(sample_diff_file)
129
+ assert out == "-2: remove me # removed"
130
+
131
+
132
+ def test_build_added_and_removed(sample_diff_file: DiffFile) -> None:
133
+ """
134
+ Should render both removed and added lines, in hunk order.
135
+ """
136
+ out = renderers.build_added_and_removed(sample_diff_file)
137
+ assert out == "-2: remove me # removed\n+3: added me # added"
138
+
139
+
140
+ # ---------- tests: *_WITH_CONTEXT ----------
141
+
142
+ def test_build_only_added_with_context(sample_diff_file: DiffFile) -> None:
143
+ """
144
+ Should render added lines plus unchanged context lines within ±1.
145
+ """
146
+ out = renderers.build_only_added_with_context(sample_diff_file, context=1)
147
+ assert out == " 2: keep B\n+3: added me # added"
148
+
149
+
150
+ def test_build_only_removed_with_context(sample_diff_file: DiffFile) -> None:
151
+ """
152
+ Should render removed lines plus unchanged context lines within ±1.
153
+ """
154
+ out = renderers.build_only_removed_with_context(sample_diff_file, context=1)
155
+ assert out == " 1: keep A\n-2: remove me # removed\n 2: keep B"
156
+
157
+
158
+ def test_build_added_and_removed_with_context(sample_diff_file: DiffFile) -> None:
159
+ """
160
+ Should render both added and removed lines, plus unchanged context within ±1.
161
+ """
162
+ out = renderers.build_added_and_removed_with_context(sample_diff_file, context=1)
163
+ assert out == (
164
+ " 1: keep A\n"
165
+ "-2: remove me # removed\n"
166
+ " 2: keep B\n"
167
+ "+3: added me # added"
168
+ )
@@ -0,0 +1,84 @@
1
+ import pytest
2
+
3
+ from ai_review import config
4
+ from ai_review.libs.config.review import ReviewMode
5
+ from ai_review.libs.diff.models import Diff, DiffFile, FileMode
6
+ from ai_review.services.diff.service import DiffService
7
+ from ai_review.tests.fixtures.git import FakeGitService
8
+
9
+
10
+ @pytest.fixture
11
+ def fake_diff_file() -> DiffFile:
12
+ return DiffFile(
13
+ header="diff --git a/x b/x",
14
+ mode=FileMode.MODIFIED,
15
+ orig_name="a/x",
16
+ new_name="b/x",
17
+ hunks=[]
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def fake_diff(fake_diff_file: DiffFile) -> Diff:
23
+ return Diff(files=[fake_diff_file], raw="raw-diff")
24
+
25
+
26
+ def test_parse_empty_returns_empty_diff():
27
+ diff = DiffService.parse("")
28
+ assert diff.files == []
29
+ assert diff.raw == ""
30
+
31
+
32
+ def test_parse_nonempty(monkeypatch: pytest.MonkeyPatch, fake_diff: Diff):
33
+ monkeypatch.setattr("ai_review.services.diff.service.DiffParser.parse", lambda _: fake_diff)
34
+ diff = DiffService.parse("something")
35
+ assert diff.files[0].new_name == "b/x"
36
+
37
+
38
+ @pytest.mark.parametrize("mode,expected_prefix", [
39
+ (ReviewMode.FULL_FILE_CURRENT, "# Failed to read current snapshot"),
40
+ (ReviewMode.FULL_FILE_PREVIOUS, "# Failed to read previous snapshot"),
41
+ (ReviewMode.FULL_FILE_DIFF, "# No matching lines for mode"),
42
+ (ReviewMode.ONLY_ADDED, "# No matching lines for mode"),
43
+ (ReviewMode.ONLY_REMOVED, "# No matching lines for mode"),
44
+ (ReviewMode.ADDED_AND_REMOVED, "# No matching lines for mode"),
45
+ (ReviewMode.ONLY_ADDED_WITH_CONTEXT, "# No matching lines for mode"),
46
+ (ReviewMode.ONLY_REMOVED_WITH_CONTEXT, "# No matching lines for mode"),
47
+ (ReviewMode.ADDED_AND_REMOVED_WITH_CONTEXT, "# No matching lines for mode"),
48
+ ])
49
+ def test_render_file_routes_to_right_builder(
50
+ mode: ReviewMode,
51
+ fake_diff: Diff,
52
+ monkeypatch: pytest.MonkeyPatch,
53
+ expected_prefix: str
54
+ ):
55
+ monkeypatch.setattr("ai_review.services.diff.service.DiffParser.parse", lambda _: fake_diff)
56
+ monkeypatch.setattr(config.settings.review, "mode", mode)
57
+
58
+ out = DiffService.render_file(raw_diff="fake", file="b/x")
59
+ assert out.file == "b/x"
60
+ assert out.diff.startswith(expected_prefix)
61
+
62
+
63
+ def test_render_file_returns_unsupported(monkeypatch: pytest.MonkeyPatch, fake_diff: Diff):
64
+ monkeypatch.setattr("ai_review.services.diff.service.DiffParser.parse", lambda _: fake_diff)
65
+ monkeypatch.setattr(config.settings.review, "mode", "NON_EXISTING")
66
+ out = DiffService.render_file(raw_diff="fake", file="b/x")
67
+ assert out.file == "b/x"
68
+ assert "# Unsupported mode" in out.diff
69
+
70
+
71
+ def test_render_files_invokes_render_file(
72
+ fake_git: FakeGitService,
73
+ fake_diff: Diff,
74
+ monkeypatch: pytest.MonkeyPatch,
75
+ ) -> None:
76
+ monkeypatch.setattr("ai_review.services.diff.service.DiffParser.parse", lambda _: fake_diff)
77
+ monkeypatch.setattr(config.settings.review, "mode", ReviewMode.FULL_FILE_DIFF)
78
+
79
+ fake_git.responses["get_diff_for_file"] = "fake-diff"
80
+
81
+ out = DiffService.render_files(git=fake_git, base_sha="A", head_sha="B", files=["b/x"])
82
+ assert out
83
+ assert out[0].file == "b/x"
84
+ assert out[0].diff.startswith("# No matching lines for mode")
@@ -0,0 +1,108 @@
1
+ # ai_review/tests/services/diff/test_tools.py
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from ai_review.libs.diff.models import Diff, DiffFile, DiffHunk, DiffRange, DiffLineType, FileMode
7
+ from ai_review.services.diff import tools
8
+ from ai_review.tests.fixtures.git import FakeGitService
9
+
10
+
11
+ # ---------- normalize_file_path ----------
12
+
13
+ @pytest.mark.parametrize(
14
+ ("inp", "expected"),
15
+ [
16
+ ("", ""),
17
+ ("./foo/bar.py", "foo/bar.py"),
18
+ ("a/foo.py", "foo.py"),
19
+ ("b\\foo.py", "foo.py"),
20
+ ("plain.py", "plain.py"),
21
+ ],
22
+ )
23
+ def test_normalize_file_path_variants(inp: str, expected: str) -> None:
24
+ assert tools.normalize_file_path(inp) == expected
25
+
26
+
27
+ # ---------- find_diff_file ----------
28
+
29
+ def make_dummy_file(orig: str = "a/x.py", new: str = "b/x.py") -> DiffFile:
30
+ hunk = DiffHunk(
31
+ header="",
32
+ orig_range=DiffRange(1, 0, []),
33
+ new_range=DiffRange(1, 0, []),
34
+ lines=[],
35
+ )
36
+ return DiffFile(header="hdr", mode=FileMode.MODIFIED, orig_name=orig, new_name=new, hunks=[hunk])
37
+
38
+
39
+ def test_find_diff_file_found_by_newname() -> None:
40
+ f = make_dummy_file(new="b/test.py")
41
+ diff = Diff(files=[f], raw="raw")
42
+ assert tools.find_diff_file(diff, "test.py") is f
43
+
44
+
45
+ def test_find_diff_file_found_by_orig_name() -> None:
46
+ f = make_dummy_file(orig="a/old.py")
47
+ diff = Diff(files=[f], raw="raw")
48
+ assert tools.find_diff_file(diff, "old.py") is f
49
+
50
+
51
+ def test_find_diff_file_not_found_returns_none() -> None:
52
+ diff = Diff(files=[make_dummy_file()], raw="raw")
53
+ assert tools.find_diff_file(diff, "not_exist.py") is None
54
+
55
+
56
+ # ---------- read_snapshot ----------
57
+
58
+ def test_read_snapshot_prefers_git(monkeypatch: pytest.MonkeyPatch, fake_git: FakeGitService) -> None:
59
+ fake_git.responses["get_file_at_commit"] = "from git"
60
+ monkeypatch.setattr(tools, "GitService", lambda: fake_git)
61
+
62
+ assert tools.read_snapshot("foo.py", head_sha="HEAD") == "from git"
63
+
64
+
65
+ def test_read_snapshot_fallback_to_filesystem(
66
+ tmp_path: Path,
67
+ fake_git: FakeGitService,
68
+ monkeypatch: pytest.MonkeyPatch,
69
+ ) -> None:
70
+ file = tmp_path / "file.txt"
71
+ file.write_text("hello")
72
+
73
+ fake_git.responses["get_file_at_commit"] = None
74
+ monkeypatch.setattr(tools, "GitService", lambda: fake_git)
75
+
76
+ result = tools.read_snapshot(str(file))
77
+ assert result == "hello"
78
+
79
+
80
+ def test_read_snapshot_returns_none_if_missing(
81
+ tmp_path: Path,
82
+ fake_git: FakeGitService,
83
+ monkeypatch: pytest.MonkeyPatch,
84
+ ) -> None:
85
+ fake_git.responses["get_file_at_commit"] = None
86
+ monkeypatch.setattr(tools, "GitService", lambda: fake_git)
87
+
88
+ assert tools.read_snapshot(str(tmp_path / "nope.txt")) is None
89
+
90
+
91
+ # ---------- marker_for_line ----------
92
+
93
+ def test_marker_for_line_added(monkeypatch: pytest.MonkeyPatch) -> None:
94
+ monkeypatch.setattr(tools.settings.review, "review_added_marker", "# A")
95
+ assert "# A" in tools.marker_for_line(DiffLineType.ADDED)
96
+ assert "# A" in tools.marker_for_line(added=True)
97
+
98
+
99
+ def test_marker_for_line_removed(monkeypatch: pytest.MonkeyPatch) -> None:
100
+ monkeypatch.setattr(tools.settings.review, "review_removed_marker", "# R")
101
+ assert "# R" in tools.marker_for_line(DiffLineType.REMOVED)
102
+ assert "# R" in tools.marker_for_line(removed=True)
103
+
104
+
105
+ def test_marker_for_line_empty(monkeypatch: pytest.MonkeyPatch) -> None:
106
+ monkeypatch.setattr(tools.settings.review, "review_added_marker", "# A")
107
+ monkeypatch.setattr(tools.settings.review, "review_removed_marker", "# R")
108
+ assert tools.marker_for_line() == ""
File without changes
@@ -0,0 +1,38 @@
1
+ from ai_review.services.prompt.schema import PromptContextSchema
2
+
3
+
4
+ def test_apply_format_inserts_variables() -> None:
5
+ context = PromptContextSchema(
6
+ merge_request_title="My MR",
7
+ merge_request_author_username="nikita"
8
+ )
9
+ template = "Title: {merge_request_title}, Author: @{merge_request_author_username}"
10
+ result = context.apply_format(template)
11
+ assert result == "Title: My MR, Author: @nikita"
12
+
13
+
14
+ def test_apply_format_with_lists() -> None:
15
+ context = PromptContextSchema(
16
+ merge_request_reviewers=["Alice", "Bob"],
17
+ merge_request_reviewers_usernames=["alice", "bob"],
18
+ labels=["bug", "feature"],
19
+ changed_files=["a.py", "b.py"],
20
+ )
21
+ template = (
22
+ "Reviewers: {merge_request_reviewers}\n"
23
+ "Usernames: {merge_request_reviewers_usernames}\n"
24
+ "Labels: {labels}\n"
25
+ "Files: {changed_files}"
26
+ )
27
+ result = context.apply_format(template)
28
+ assert "Alice, Bob" in result
29
+ assert "alice, bob" in result
30
+ assert "bug, feature" in result
31
+ assert "a.py, b.py" in result
32
+
33
+
34
+ def test_apply_format_handles_missing_fields() -> None:
35
+ context = PromptContextSchema()
36
+ template = "Title: {merge_request_title}, Reviewer: {merge_request_reviewer}"
37
+ result = context.apply_format(template)
38
+ assert result == "Title: , Reviewer: "
@@ -0,0 +1,128 @@
1
+ import pytest
2
+
3
+ from ai_review.libs.config.prompt import PromptConfig
4
+ from ai_review.services.diff.schema import DiffFileSchema
5
+ from ai_review.services.prompt.schema import PromptContextSchema
6
+ from ai_review.services.prompt.service import PromptService
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def patch_prompts(monkeypatch: pytest.MonkeyPatch) -> None:
11
+ """Patch methods of settings.prompt to return dummy values."""
12
+ monkeypatch.setattr(PromptConfig, "load_inline", lambda self: ["GLOBAL_INLINE", "INLINE_PROMPT"])
13
+ monkeypatch.setattr(PromptConfig, "load_context", lambda self: ["GLOBAL_CONTEXT", "CONTEXT_PROMPT"])
14
+ monkeypatch.setattr(PromptConfig, "load_summary", lambda self: ["GLOBAL_SUMMARY", "SUMMARY_PROMPT"])
15
+ monkeypatch.setattr(PromptConfig, "load_system_inline", lambda self: ["SYS_INLINE_A", "SYS_INLINE_B"])
16
+ monkeypatch.setattr(PromptConfig, "load_system_context", lambda self: ["SYS_CONTEXT_A", "SYS_CONTEXT_B"])
17
+ monkeypatch.setattr(PromptConfig, "load_system_summary", lambda self: ["SYS_SUMMARY_A", "SYS_SUMMARY_B"])
18
+
19
+
20
+ @pytest.fixture
21
+ def dummy_context() -> PromptContextSchema:
22
+ return PromptContextSchema(
23
+ merge_request_title="Fix login bug",
24
+ merge_request_description="Some description",
25
+ merge_request_author_name="Nikita",
26
+ merge_request_author_username="nikita.filonov",
27
+ merge_request_reviewers=["Alice", "Bob"],
28
+ merge_request_reviewers_usernames=["alice", "bob"],
29
+ merge_request_assignees=["Charlie"],
30
+ merge_request_assignees_usernames=["charlie"],
31
+ source_branch="feature/login-fix",
32
+ target_branch="main",
33
+ labels=["bug", "critical"],
34
+ changed_files=["foo.py", "bar.py"],
35
+ )
36
+
37
+
38
+ def test_build_inline_request_includes_prompts_and_diff(dummy_context: PromptContextSchema) -> None:
39
+ diff = DiffFileSchema(file="foo.py", diff="+ added line\n- removed line")
40
+ result = PromptService.build_inline_request(diff, dummy_context)
41
+
42
+ assert "GLOBAL_INLINE" in result
43
+ assert "INLINE_PROMPT" in result
44
+ assert "# File: foo.py" in result
45
+ assert "+ added line" in result
46
+ assert "- removed line" in result
47
+
48
+
49
+ def test_build_summary_request_includes_prompts_and_diffs(dummy_context: PromptContextSchema) -> None:
50
+ diffs = [
51
+ DiffFileSchema(file="a.py", diff="+ foo"),
52
+ DiffFileSchema(file="b.py", diff="- bar"),
53
+ ]
54
+ result = PromptService.build_summary_request(diffs, dummy_context)
55
+
56
+ assert "GLOBAL_SUMMARY" in result
57
+ assert "SUMMARY_PROMPT" in result
58
+ assert "# File: a.py" in result
59
+ assert "# File: b.py" in result
60
+ assert "+ foo" in result
61
+ assert "- bar" in result
62
+
63
+
64
+ def test_build_summary_request_empty_list(dummy_context: PromptContextSchema) -> None:
65
+ """Empty diffs list should still produce valid prompt with no diff content."""
66
+ result = PromptService.build_summary_request([], dummy_context)
67
+
68
+ assert "GLOBAL_SUMMARY" in result
69
+ assert "SUMMARY_PROMPT" in result
70
+ assert "## Changes" in result
71
+ assert result.strip().endswith("## Changes")
72
+
73
+
74
+ def test_build_context_request_includes_prompts_and_diffs(dummy_context: PromptContextSchema) -> None:
75
+ diffs = [
76
+ DiffFileSchema(file="a.py", diff="+ foo"),
77
+ DiffFileSchema(file="b.py", diff="- bar"),
78
+ ]
79
+ result = PromptService.build_context_request(diffs, dummy_context)
80
+
81
+ assert "GLOBAL_CONTEXT" in result
82
+ assert "CONTEXT_PROMPT" in result
83
+ assert "# File: a.py" in result
84
+ assert "# File: b.py" in result
85
+ assert "+ foo" in result
86
+ assert "- bar" in result
87
+
88
+
89
+ def test_build_system_inline_request_returns_joined_prompts(dummy_context: PromptContextSchema) -> None:
90
+ result = PromptService.build_system_inline_request(dummy_context)
91
+ assert result == "SYS_INLINE_A\n\nSYS_INLINE_B".replace("SYS_INLINE_A", "SYS_INLINE_A")
92
+
93
+
94
+ def test_build_system_context_request_returns_joined_prompts(dummy_context: PromptContextSchema) -> None:
95
+ result = PromptService.build_system_context_request(dummy_context)
96
+ assert result == "SYS_CONTEXT_A\n\nSYS_CONTEXT_B"
97
+
98
+
99
+ def test_build_system_summary_request_returns_joined_prompts(dummy_context: PromptContextSchema) -> None:
100
+ result = PromptService.build_system_summary_request(dummy_context)
101
+ assert result == "SYS_SUMMARY_A\n\nSYS_SUMMARY_B"
102
+
103
+
104
+ def test_build_system_inline_request_empty(
105
+ monkeypatch: pytest.MonkeyPatch,
106
+ dummy_context: PromptContextSchema
107
+ ) -> None:
108
+ monkeypatch.setattr(PromptConfig, "load_system_inline", lambda self: [])
109
+ result = PromptService.build_system_inline_request(dummy_context)
110
+ assert result == ""
111
+
112
+
113
+ def test_build_system_context_request_empty(
114
+ monkeypatch: pytest.MonkeyPatch,
115
+ dummy_context: PromptContextSchema
116
+ ) -> None:
117
+ monkeypatch.setattr(PromptConfig, "load_system_context", lambda self: [])
118
+ result = PromptService.build_system_context_request(dummy_context)
119
+ assert result == ""
120
+
121
+
122
+ def test_build_system_summary_request_empty(
123
+ monkeypatch: pytest.MonkeyPatch,
124
+ dummy_context: PromptContextSchema
125
+ ) -> None:
126
+ monkeypatch.setattr(PromptConfig, "load_system_summary", lambda self: [])
127
+ result = PromptService.build_system_summary_request(dummy_context)
128
+ assert result == ""
File without changes
@@ -0,0 +1,65 @@
1
+ from ai_review.config import settings
2
+ from ai_review.services.review.inline.schema import (
3
+ InlineCommentSchema,
4
+ InlineCommentListSchema,
5
+ )
6
+
7
+
8
+ def test_normalize_file_and_message():
9
+ comment = InlineCommentSchema(file=" \\src\\main.py ", line=10, message=" fix bug ")
10
+ assert comment.file == "src/main.py" # нормализуется и слеши, и пробелы
11
+ assert comment.message == "fix bug" # пробелы убраны
12
+
13
+
14
+ def test_body_without_suggestion():
15
+ comment = InlineCommentSchema(file="a.py", line=1, message="use f-string")
16
+ assert comment.body == "use f-string"
17
+ assert settings.review.inline_tag not in comment.body # тег ещё не добавлен
18
+
19
+
20
+ def test_body_with_suggestion():
21
+ comment = InlineCommentSchema(
22
+ file="a.py",
23
+ line=2,
24
+ message="replace concatenation with f-string",
25
+ suggestion='print(f"Hello {name}")',
26
+ )
27
+ expected = (
28
+ "replace concatenation with f-string\n\n"
29
+ "```suggestion\nprint(f\"Hello {name}\")\n```"
30
+ )
31
+ assert comment.body == expected
32
+
33
+
34
+ def test_body_with_tag(monkeypatch):
35
+ monkeypatch.setattr(settings.review, "inline_tag", "#ai-inline")
36
+ comment = InlineCommentSchema(file="a.py", line=3, message="something")
37
+ assert comment.body_with_tag.endswith("\n\n#ai-inline")
38
+
39
+
40
+ def test_fallback_body_with_tag(monkeypatch):
41
+ monkeypatch.setattr(settings.review, "inline_tag", "#ai-inline")
42
+ comment = InlineCommentSchema(file="a.py", line=42, message="missing check")
43
+ body = comment.fallback_body_with_tag
44
+ assert body.startswith("**a.py:42** — missing check")
45
+ assert "#ai-inline" in body
46
+
47
+
48
+ def test_dedup_key_differs_on_message_and_suggestion():
49
+ c1 = InlineCommentSchema(file="a.py", line=1, message="msg one")
50
+ c2 = InlineCommentSchema(file="a.py", line=1, message="msg one", suggestion="x = 1")
51
+ assert c1.dedup_key != c2.dedup_key
52
+
53
+
54
+ def test_list_dedupe_removes_duplicates():
55
+ c1 = InlineCommentSchema(file="a.py", line=1, message="msg one")
56
+ c2 = InlineCommentSchema(file="a.py", line=1, message="msg one") # дубликат
57
+ c3 = InlineCommentSchema(file="a.py", line=2, message="msg two")
58
+
59
+ comment_list = InlineCommentListSchema(root=[c1, c2, c3])
60
+ comment_list = comment_list.dedupe()
61
+
62
+ assert len(comment_list.root) == 2
63
+ dedup_messages = [c.message for c in comment_list.root]
64
+ assert "msg one" in dedup_messages
65
+ assert "msg two" in dedup_messages
@@ -0,0 +1,49 @@
1
+ from ai_review.services.review.inline.schema import InlineCommentListSchema
2
+ from ai_review.services.review.inline.service import InlineCommentService
3
+
4
+
5
+ def test_empty_output_returns_empty_list():
6
+ result = InlineCommentService.parse_model_output("")
7
+ assert isinstance(result, InlineCommentListSchema)
8
+ assert result.root == []
9
+
10
+
11
+ def test_valid_json_array_parsed():
12
+ json_output = '[{"file": "a.py", "line": 1, "message": "use f-string"}]'
13
+ result = InlineCommentService.parse_model_output(json_output)
14
+ assert len(result.root) == 1
15
+ assert result.root[0].file == "a.py"
16
+ assert result.root[0].line == 1
17
+ assert result.root[0].message == "use f-string"
18
+
19
+
20
+ def test_json_inside_code_block_parsed():
21
+ output = """```json
22
+ [
23
+ {"file": "b.py", "line": 42, "message": "check for None"}
24
+ ]
25
+ ```"""
26
+ result = InlineCommentService.parse_model_output(output)
27
+ assert len(result.root) == 1
28
+ assert result.root[0].file == "b.py"
29
+ assert result.root[0].line == 42
30
+
31
+
32
+ def test_non_json_but_array_inside_text():
33
+ output = "some explanation...\n[ {\"file\": \"c.py\", \"line\": 7, \"message\": \"fix this\"} ]\nend"
34
+ result = InlineCommentService.parse_model_output(output)
35
+ assert len(result.root) == 1
36
+ assert result.root[0].file == "c.py"
37
+ assert result.root[0].line == 7
38
+
39
+
40
+ def test_invalid_json_array_logs_and_returns_empty():
41
+ output = '[{"file": "d.py", "line": "oops", "message": "bad"}]'
42
+ result = InlineCommentService.parse_model_output(output)
43
+ assert result.root == []
44
+
45
+
46
+ def test_no_json_array_found_logs_and_returns_empty():
47
+ output = "this is not json at all"
48
+ result = InlineCommentService.parse_model_output(output)
49
+ assert result.root == []