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,31 @@
1
+ import pytest
2
+ from httpx import AsyncClient
3
+ from pydantic import HttpUrl, SecretStr
4
+
5
+ from ai_review.clients.claude.client import get_claude_http_client, ClaudeHTTPClient
6
+ from ai_review.config import settings
7
+ from ai_review.libs.config.claude import ClaudeMetaConfig
8
+ from ai_review.libs.config.llm import ClaudeLLMConfig, ClaudeHTTPClientConfig
9
+ from ai_review.libs.constants.llm_provider import LLMProvider
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def claude_http_client_config(monkeypatch):
14
+ fake_config = ClaudeLLMConfig(
15
+ meta=ClaudeMetaConfig(),
16
+ provider=LLMProvider.CLAUDE,
17
+ http_client=ClaudeHTTPClientConfig(
18
+ timeout=10,
19
+ api_url=HttpUrl("https://api.anthropic.com"),
20
+ api_token=SecretStr("fake-token"),
21
+ api_version="2023-06-01",
22
+ )
23
+ )
24
+ monkeypatch.setattr(settings, "llm", fake_config)
25
+
26
+
27
+ def test_get_claude_http_client_builds_ok():
28
+ claude_http_client = get_claude_http_client()
29
+
30
+ assert isinstance(claude_http_client, ClaudeHTTPClient)
31
+ assert isinstance(claude_http_client.client, AsyncClient)
@@ -0,0 +1,59 @@
1
+ from ai_review.clients.claude.schema import (
2
+ ClaudeUsageSchema,
3
+ ClaudeContentSchema,
4
+ ClaudeMessageSchema,
5
+ ClaudeChatRequestSchema,
6
+ ClaudeChatResponseSchema,
7
+ )
8
+
9
+
10
+ # ---------- ClaudeUsageSchema ----------
11
+
12
+ def test_usage_total_tokens_property():
13
+ usage = ClaudeUsageSchema(input_tokens=10, output_tokens=5)
14
+ assert usage.total_tokens == 15
15
+
16
+
17
+ # ---------- ClaudeChatResponseSchema ----------
18
+
19
+ def test_first_text_returns_text():
20
+ resp = ClaudeChatResponseSchema(
21
+ id="123",
22
+ role="assistant",
23
+ usage=ClaudeUsageSchema(input_tokens=3, output_tokens=7),
24
+ content=[
25
+ ClaudeContentSchema(type="text", text=" hello world 1 "),
26
+ ClaudeContentSchema(type="text", text=" hello world 2 "),
27
+ ],
28
+ )
29
+ assert resp.first_text == "hello world 1"
30
+
31
+
32
+ def test_first_text_empty_if_no_content():
33
+ resp = ClaudeChatResponseSchema(
34
+ id="123",
35
+ role="assistant",
36
+ usage=ClaudeUsageSchema(input_tokens=1, output_tokens=2),
37
+ content=[],
38
+ )
39
+ assert resp.first_text == ""
40
+
41
+
42
+ # ---------- ClaudeChatRequestSchema ----------
43
+
44
+ def test_chat_request_schema_builds_ok():
45
+ msg = ClaudeMessageSchema(role="user", content="hello")
46
+ req = ClaudeChatRequestSchema(
47
+ model="claude-3-sonnet",
48
+ system="You are a helpful assistant",
49
+ messages=[msg],
50
+ max_tokens=512,
51
+ temperature=0.3,
52
+ )
53
+
54
+ assert req.model == "claude-3-sonnet"
55
+ assert req.system == "You are a helpful assistant"
56
+ assert req.messages[0].role == "user"
57
+ assert req.messages[0].content == "hello"
58
+ assert req.max_tokens == 512
59
+ assert req.temperature == 0.3
File without changes
@@ -0,0 +1,30 @@
1
+ import pytest
2
+ from httpx import AsyncClient
3
+ from pydantic import HttpUrl, SecretStr
4
+
5
+ from ai_review.clients.gemini.client import get_gemini_http_client, GeminiHTTPClient
6
+ from ai_review.config import settings
7
+ from ai_review.libs.config.gemini import GeminiMetaConfig, GeminiHTTPClientConfig
8
+ from ai_review.libs.config.llm import GeminiLLMConfig
9
+ from ai_review.libs.constants.llm_provider import LLMProvider
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def gemini_http_client_config(monkeypatch):
14
+ fake_config = GeminiLLMConfig(
15
+ meta=GeminiMetaConfig(),
16
+ provider=LLMProvider.GEMINI,
17
+ http_client=GeminiHTTPClientConfig(
18
+ timeout=10,
19
+ api_url=HttpUrl("https://generativelanguage.googleapis.com"),
20
+ api_token=SecretStr("fake-token"),
21
+ )
22
+ )
23
+ monkeypatch.setattr(settings, "llm", fake_config)
24
+
25
+
26
+ def test_get_gemini_http_client_builds_ok():
27
+ gemini_http_client = get_gemini_http_client()
28
+
29
+ assert isinstance(gemini_http_client, GeminiHTTPClient)
30
+ assert isinstance(gemini_http_client.client, AsyncClient)
@@ -0,0 +1,105 @@
1
+ from ai_review.clients.gemini.schema import (
2
+ GeminiPartSchema,
3
+ GeminiUsageSchema,
4
+ GeminiContentSchema,
5
+ GeminiCandidateSchema,
6
+ GeminiGenerationConfigSchema,
7
+ GeminiChatRequestSchema,
8
+ GeminiChatResponseSchema,
9
+ )
10
+
11
+
12
+ # ---------- GeminiUsageSchema ----------
13
+
14
+ def test_usage_total_tokens_prefers_total_tokens_count():
15
+ usage = GeminiUsageSchema(
16
+ prompt_token_count=10,
17
+ total_tokens_count=99,
18
+ candidates_token_count=5,
19
+ output_thoughts_token_count=3,
20
+ )
21
+ assert usage.total_tokens == 99 # приоритет у totalTokenCount
22
+
23
+
24
+ def test_usage_total_tokens_falls_back_to_sum():
25
+ usage = GeminiUsageSchema(
26
+ prompt_token_count=10,
27
+ candidates_token_count=5,
28
+ output_thoughts_token_count=3,
29
+ )
30
+ # 10 + 5 + 3
31
+ assert usage.total_tokens == 18
32
+
33
+
34
+ def test_usage_prompt_tokens_and_completion_from_candidates():
35
+ usage = GeminiUsageSchema(
36
+ prompt_token_count=7,
37
+ candidates_token_count=2,
38
+ )
39
+ assert usage.prompt_tokens == 7
40
+ assert usage.completion_tokens == 2
41
+
42
+
43
+ def test_usage_completion_tokens_from_output_thoughts():
44
+ usage = GeminiUsageSchema(
45
+ prompt_token_count=7,
46
+ output_thoughts_token_count=5,
47
+ )
48
+ assert usage.completion_tokens == 5
49
+
50
+
51
+ def test_usage_completion_tokens_none_if_not_provided():
52
+ usage = GeminiUsageSchema(prompt_token_count=7)
53
+ assert usage.completion_tokens is None
54
+
55
+
56
+ # ---------- GeminiChatResponseSchema ----------
57
+
58
+ def test_first_text_returns_text():
59
+ resp = GeminiChatResponseSchema(
60
+ usage=GeminiUsageSchema(prompt_token_count=3, total_tokens_count=5),
61
+ candidates=[
62
+ GeminiCandidateSchema(
63
+ content=GeminiContentSchema(
64
+ role="model",
65
+ parts=[GeminiPartSchema(text=" hello world ")],
66
+ )
67
+ )
68
+ ],
69
+ )
70
+ assert resp.first_text == "hello world"
71
+
72
+
73
+ def test_first_text_empty_if_no_candidates():
74
+ resp = GeminiChatResponseSchema(
75
+ usage=GeminiUsageSchema(prompt_token_count=1, total_tokens_count=2),
76
+ candidates=[],
77
+ )
78
+ assert resp.first_text == ""
79
+
80
+
81
+ def test_first_text_empty_if_parts_missing():
82
+ resp = GeminiChatResponseSchema(
83
+ usage=GeminiUsageSchema(prompt_token_count=1, total_tokens_count=2),
84
+ candidates=[
85
+ GeminiCandidateSchema(content=GeminiContentSchema(role="model", parts=[]))
86
+ ],
87
+ )
88
+ assert resp.first_text == ""
89
+
90
+
91
+ # ---------- GeminiChatRequestSchema ----------
92
+
93
+ def test_chat_request_schema_builds_ok():
94
+ content = GeminiContentSchema(role="user", parts=[GeminiPartSchema(text="hi")])
95
+ gen_cfg = GeminiGenerationConfigSchema(temperature=0.5, max_output_tokens=128)
96
+
97
+ req = GeminiChatRequestSchema(
98
+ contents=[content],
99
+ generation_config=gen_cfg,
100
+ system_instruction=GeminiContentSchema(role="system", parts=[GeminiPartSchema(text="sys")]),
101
+ )
102
+
103
+ assert req.contents[0].parts[0].text == "hi"
104
+ assert req.generation_config.max_output_tokens == 128
105
+ assert req.system_instruction.parts[0].text == "sys"
File without changes
@@ -0,0 +1,30 @@
1
+ import pytest
2
+ from httpx import AsyncClient
3
+ from pydantic import HttpUrl, SecretStr
4
+
5
+ from ai_review.clients.openai.client import get_openai_http_client, OpenAIHTTPClient
6
+ from ai_review.config import settings
7
+ from ai_review.libs.config.llm import OpenAILLMConfig
8
+ from ai_review.libs.config.openai import OpenAIMetaConfig, OpenAIHTTPClientConfig
9
+ from ai_review.libs.constants.llm_provider import LLMProvider
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def openai_http_client_config(monkeypatch):
14
+ fake_config = OpenAILLMConfig(
15
+ meta=OpenAIMetaConfig(),
16
+ provider=LLMProvider.OPENAI,
17
+ http_client=OpenAIHTTPClientConfig(
18
+ timeout=10,
19
+ api_url=HttpUrl("https://api.openai.com/v1"),
20
+ api_token=SecretStr("fake-token"),
21
+ )
22
+ )
23
+ monkeypatch.setattr(settings, "llm", fake_config)
24
+
25
+
26
+ def test_get_openai_http_client_builds_ok():
27
+ openai_http_client = get_openai_http_client()
28
+
29
+ assert isinstance(openai_http_client, OpenAIHTTPClient)
30
+ assert isinstance(openai_http_client.client, AsyncClient)
@@ -0,0 +1,53 @@
1
+ from ai_review.clients.openai.schema import (
2
+ OpenAIUsageSchema,
3
+ OpenAIMessageSchema,
4
+ OpenAIChoiceSchema,
5
+ OpenAIChatRequestSchema,
6
+ OpenAIChatResponseSchema,
7
+ )
8
+
9
+
10
+ # ---------- OpenAIChatResponseSchema ----------
11
+
12
+ def test_first_text_returns_text():
13
+ resp = OpenAIChatResponseSchema(
14
+ usage=OpenAIUsageSchema(total_tokens=5, prompt_tokens=2, completion_tokens=3),
15
+ choices=[
16
+ OpenAIChoiceSchema(
17
+ message=OpenAIMessageSchema(role="assistant", content=" hello world ")
18
+ )
19
+ ],
20
+ )
21
+ assert resp.first_text == "hello world"
22
+
23
+
24
+ def test_first_text_empty_if_no_choices():
25
+ resp = OpenAIChatResponseSchema(
26
+ usage=OpenAIUsageSchema(total_tokens=1, prompt_tokens=1, completion_tokens=0),
27
+ choices=[],
28
+ )
29
+ assert resp.first_text == ""
30
+
31
+
32
+ def test_first_text_strips_and_handles_empty_content():
33
+ resp = OpenAIChatResponseSchema(
34
+ usage=OpenAIUsageSchema(total_tokens=1, prompt_tokens=1, completion_tokens=0),
35
+ choices=[OpenAIChoiceSchema(message=OpenAIMessageSchema(role="assistant", content=" "))],
36
+ )
37
+ assert resp.first_text == ""
38
+
39
+
40
+ # ---------- OpenAIChatRequestSchema ----------
41
+
42
+ def test_chat_request_schema_builds_ok():
43
+ msg = OpenAIMessageSchema(role="user", content="hello")
44
+ req = OpenAIChatRequestSchema(
45
+ model="gpt-4o-mini",
46
+ messages=[msg],
47
+ max_tokens=100,
48
+ temperature=0.3,
49
+ )
50
+ assert req.model == "gpt-4o-mini"
51
+ assert req.messages[0].content == "hello"
52
+ assert req.max_tokens == 100
53
+ assert req.temperature == 0.3
File without changes
File without changes
@@ -0,0 +1,105 @@
1
+ import pytest
2
+
3
+ from ai_review.libs.diff.models import (
4
+ DiffLine,
5
+ DiffLineType,
6
+ DiffRange,
7
+ DiffHunk,
8
+ DiffFile,
9
+ Diff,
10
+ FileMode,
11
+ )
12
+
13
+
14
+ # ---------- fixtures ----------
15
+
16
+ @pytest.fixture
17
+ def diff_file_modified() -> DiffFile:
18
+ """
19
+ Create a DiffFile with a single hunk containing:
20
+ - one unchanged line "A"
21
+ - one removed line "X"
22
+ - one unchanged line "B"
23
+ - one added line "Y"
24
+ """
25
+ orig_lines = [
26
+ DiffLine(DiffLineType.UNCHANGED, 1, "A", 1),
27
+ DiffLine(DiffLineType.REMOVED, 2, "X", 2),
28
+ DiffLine(DiffLineType.UNCHANGED, 3, "B", 3),
29
+ ]
30
+ new_lines = [
31
+ DiffLine(DiffLineType.UNCHANGED, 1, "A", 1),
32
+ DiffLine(DiffLineType.UNCHANGED, 2, "B", 2),
33
+ DiffLine(DiffLineType.ADDED, 3, "Y", 3),
34
+ ]
35
+
36
+ hunk = DiffHunk(
37
+ header="test hunk",
38
+ orig_range=DiffRange(start=1, length=3, lines=orig_lines),
39
+ new_range=DiffRange(start=1, length=3, lines=new_lines),
40
+ lines=[*orig_lines, new_lines[-1]],
41
+ )
42
+
43
+ return DiffFile(
44
+ header="diff --git a/file b/file",
45
+ mode=FileMode.MODIFIED,
46
+ orig_name="a/file",
47
+ new_name="b/file",
48
+ hunks=[hunk],
49
+ )
50
+
51
+
52
+ @pytest.fixture
53
+ def diff_with_modified_file(diff_file_modified: DiffFile) -> Diff:
54
+ """Return a Diff object containing a single modified file."""
55
+ return Diff(files=[diff_file_modified], raw="raw-diff-here")
56
+
57
+
58
+ # ---------- tests ----------
59
+
60
+ def test_added_and_removed_lines(diff_file_modified: DiffFile) -> None:
61
+ """added_new_lines/removed_old_lines should return correct DiffLine objects."""
62
+ added = [line.content for line in diff_file_modified.added_new_lines()]
63
+ removed = [line.content for line in diff_file_modified.removed_old_lines()]
64
+
65
+ assert added == ["Y"]
66
+ assert removed == ["X"]
67
+
68
+
69
+ def test_added_and_removed_line_numbers(diff_file_modified: DiffFile) -> None:
70
+ """added_line_numbers/removed_line_numbers should return correct sets of numbers."""
71
+ assert diff_file_modified.added_line_numbers() == {3}
72
+ assert diff_file_modified.removed_line_numbers() == {2}
73
+
74
+
75
+ def test_diff_summary(diff_with_modified_file: Diff) -> None:
76
+ """Diff.summary should include file mode, file name, and hunk info."""
77
+ summary = diff_with_modified_file.summary()
78
+
79
+ assert "MODIFIED b/file" in summary
80
+ assert "Hunk: test hunk" in summary
81
+ assert "(4 lines)" in summary # total lines in hunk.lines
82
+
83
+
84
+ def test_changed_lines_and_files(diff_with_modified_file: Diff) -> None:
85
+ """changed_lines should map added line numbers; changed_files should list modified files."""
86
+ changed = diff_with_modified_file.changed_lines()
87
+ files = diff_with_modified_file.changed_files()
88
+
89
+ assert changed == {"b/file": [3]}
90
+ assert files == ["b/file"]
91
+
92
+
93
+ def test_changed_files_skips_deleted(diff_file_modified: DiffFile) -> None:
94
+ """Files with mode=DELETED should not appear in changed_files or changed_lines."""
95
+ deleted_file = DiffFile(
96
+ header="diff --git a/deleted b/deleted",
97
+ mode=FileMode.DELETED,
98
+ orig_name="a/deleted",
99
+ new_name="b/deleted",
100
+ hunks=[],
101
+ )
102
+ diff = Diff(files=[diff_file_modified, deleted_file], raw="raw")
103
+
104
+ assert "b/deleted" not in diff.changed_files()
105
+ assert "b/deleted" not in diff.changed_lines()
@@ -0,0 +1,115 @@
1
+ from ai_review.libs.diff.models import FileMode, DiffLineType, DiffFile
2
+ from ai_review.libs.diff.parser import DiffParser
3
+
4
+
5
+ # ---------- helpers ----------
6
+
7
+
8
+ def parse_and_get_file(raw_diff: str) -> DiffFile:
9
+ """Helper: parse diff and return the first file."""
10
+ diff = DiffParser.parse(raw_diff)
11
+ assert diff.files, "Expected at least one parsed file"
12
+ return diff.files[0]
13
+
14
+
15
+ # ---------- tests ----------
16
+
17
+ def test_parse_added_lines_only() -> None:
18
+ """Should correctly parse diff with only added lines."""
19
+ raw_diff = """diff --git a/x b/x
20
+ index 0000000..1111111 100644
21
+ --- a/x
22
+ +++ b/x
23
+ @@ -0,0 +1,2 @@
24
+ +line1
25
+ +line2
26
+ """
27
+ file = parse_and_get_file(raw_diff)
28
+
29
+ assert file.mode == FileMode.MODIFIED
30
+ assert file.orig_name == "x"
31
+ assert file.new_name == "x"
32
+ assert len(file.hunks) == 1
33
+
34
+ added_lines: list[str] = [
35
+ line.content for line in file.hunks[0].new_range.lines if line.type is DiffLineType.ADDED
36
+ ]
37
+ assert added_lines == ["line1", "line2"]
38
+
39
+
40
+ def test_parse_removed_lines_only() -> None:
41
+ """Should correctly parse diff with only removed lines."""
42
+ raw_diff = """diff --git a/x b/x
43
+ index 2222222..3333333 100644
44
+ --- a/x
45
+ +++ b/x
46
+ @@ -1,2 +0,0 @@
47
+ -line1
48
+ -line2
49
+ """
50
+ file = parse_and_get_file(raw_diff)
51
+
52
+ assert file.mode == FileMode.MODIFIED
53
+ removed_lines: list[str] = [
54
+ line.content for line in file.hunks[0].orig_range.lines if line.type is DiffLineType.REMOVED
55
+ ]
56
+ assert removed_lines == ["line1", "line2"]
57
+
58
+
59
+ def test_parse_added_and_removed_lines() -> None:
60
+ """Should parse diff with added, removed and unchanged lines."""
61
+ raw_diff = """diff --git a/x b/x
62
+ index 4444444..5555555 100644
63
+ --- a/x
64
+ +++ b/x
65
+ @@ -1,3 +1,3 @@
66
+ line1
67
+ -line2
68
+ +line2_changed
69
+ line3
70
+ """
71
+ file = parse_and_get_file(raw_diff)
72
+ hunk = file.hunks[0]
73
+
74
+ assert [line.content for line in hunk.lines] == [
75
+ "line1",
76
+ "line2",
77
+ "line2_changed",
78
+ "line3",
79
+ ]
80
+ assert hunk.lines[0].type == DiffLineType.UNCHANGED
81
+ assert hunk.lines[1].type == DiffLineType.REMOVED
82
+ assert hunk.lines[2].type == DiffLineType.ADDED
83
+ assert hunk.lines[3].type == DiffLineType.UNCHANGED
84
+
85
+
86
+ def test_parse_new_file_mode() -> None:
87
+ """Should mark file as NEW when old side is /dev/null."""
88
+ raw_diff = """diff --git a/x b/x
89
+ new file mode 100644
90
+ --- /dev/null
91
+ +++ b/x
92
+ @@ -0,0 +1,1 @@
93
+ +new line
94
+ """
95
+ file = parse_and_get_file(raw_diff)
96
+
97
+ assert file.mode == FileMode.NEW
98
+ assert file.new_name == "x"
99
+ assert [line.content for line in file.hunks[0].new_range.lines] == ["new line"]
100
+
101
+
102
+ def test_parse_deleted_file_mode() -> None:
103
+ """Should mark file as DELETED when new side is /dev/null."""
104
+ raw_diff = """diff --git a/x b/x
105
+ deleted file mode 100644
106
+ --- a/x
107
+ +++ /dev/null
108
+ @@ -1,1 +0,0 @@
109
+ -old line
110
+ """
111
+ file = parse_and_get_file(raw_diff)
112
+
113
+ assert file.mode == FileMode.DELETED
114
+ assert file.orig_name == "x"
115
+ assert [line.content for line in file.hunks[0].orig_range.lines] == ["old line"]
@@ -0,0 +1,62 @@
1
+ import pytest
2
+
3
+ from ai_review.libs.diff import tools
4
+ from ai_review.libs.diff.models import DiffLineType
5
+
6
+
7
+ # ---------- tests: is_source_line ----------
8
+
9
+ def test_is_source_line_regular_added():
10
+ """Should return True for a normal added line (+content)."""
11
+ assert tools.is_source_line("+foo") is True
12
+
13
+
14
+ def test_is_source_line_regular_removed():
15
+ """Should return True for a normal removed line (-content)."""
16
+ assert tools.is_source_line("-bar") is True
17
+
18
+
19
+ def test_is_source_line_regular_unchanged():
20
+ """Should return True for a normal unchanged line (space prefix)."""
21
+ assert tools.is_source_line(" baz") is True
22
+
23
+
24
+ def test_is_source_line_no_newline_marker():
25
+ """Should return False for the special diff marker line."""
26
+ assert tools.is_source_line(r"") is False
27
+
28
+
29
+ def test_is_source_line_empty_or_headers():
30
+ """Should return False for empty line or diff headers (---/+++)."""
31
+ assert tools.is_source_line("") is False
32
+ assert tools.is_source_line("--- a/file.py") is False
33
+ assert tools.is_source_line("+++ b/file.py") is False
34
+
35
+
36
+ # ---------- tests: get_line_type ----------
37
+
38
+ def test_get_line_type_added():
39
+ """Should classify '+' prefix as ADDED."""
40
+ assert tools.get_line_type("+foo") == DiffLineType.ADDED
41
+
42
+
43
+ def test_get_line_type_removed():
44
+ """Should classify '-' prefix as REMOVED."""
45
+ assert tools.get_line_type("-bar") == DiffLineType.REMOVED
46
+
47
+
48
+ def test_get_line_type_unchanged():
49
+ """Should classify ' ' prefix as UNCHANGED."""
50
+ assert tools.get_line_type(" baz") == DiffLineType.UNCHANGED
51
+
52
+
53
+ def test_get_line_type_empty_raises():
54
+ """Should raise ValueError if line is empty."""
55
+ with pytest.raises(ValueError):
56
+ tools.get_line_type("")
57
+
58
+
59
+ def test_get_line_type_unknown_prefix_raises():
60
+ """Should raise ValueError if line starts with unknown prefix."""
61
+ with pytest.raises(ValueError):
62
+ tools.get_line_type("@@ -1,2 +1,2 @@")
File without changes
File without changes