xai-review 0.7.0__tar.gz → 0.10.0__tar.gz

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 (172) hide show
  1. {xai_review-0.7.0 → xai_review-0.10.0}/PKG-INFO +2 -2
  2. {xai_review-0.7.0 → xai_review-0.10.0}/README.md +1 -1
  3. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/client.py +1 -1
  4. xai_review-0.10.0/ai_review/libs/json.py +19 -0
  5. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/diff/renderers.py +19 -12
  6. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/prompt/adapter.py +1 -1
  7. xai_review-0.10.0/ai_review/services/review/inline/service.py +53 -0
  8. xai_review-0.10.0/ai_review/tests/suites/libs/test_json.py +41 -0
  9. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/diff/test_renderers.py +23 -0
  10. xai_review-0.10.0/ai_review/tests/suites/services/review/inline/test_service.py +101 -0
  11. {xai_review-0.7.0 → xai_review-0.10.0}/pyproject.toml +1 -1
  12. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/PKG-INFO +2 -2
  13. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/SOURCES.txt +2 -0
  14. xai_review-0.7.0/ai_review/services/review/inline/service.py +0 -38
  15. xai_review-0.7.0/ai_review/tests/suites/services/review/inline/test_service.py +0 -49
  16. {xai_review-0.7.0 → xai_review-0.10.0}/LICENSE +0 -0
  17. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/__init__.py +0 -0
  18. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/__init__.py +0 -0
  19. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/commands/__init__.py +0 -0
  20. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/commands/run_context_review.py +0 -0
  21. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/commands/run_inline_review.py +0 -0
  22. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/commands/run_review.py +0 -0
  23. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/commands/run_summary_review.py +0 -0
  24. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/cli/main.py +0 -0
  25. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/__init__.py +0 -0
  26. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/claude/__init__.py +0 -0
  27. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/claude/client.py +0 -0
  28. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/claude/schema.py +0 -0
  29. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gemini/__init__.py +0 -0
  30. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gemini/client.py +0 -0
  31. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gemini/schema.py +0 -0
  32. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/__init__.py +0 -0
  33. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/__init__.py +0 -0
  34. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/client.py +0 -0
  35. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/schema/__init__.py +0 -0
  36. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/schema/changes.py +0 -0
  37. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/schema/comments.py +0 -0
  38. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/gitlab/mr/schema/discussions.py +0 -0
  39. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/openai/__init__.py +0 -0
  40. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/openai/client.py +0 -0
  41. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/clients/openai/schema.py +0 -0
  42. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/config.py +0 -0
  43. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/__init__.py +0 -0
  44. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/asynchronous/__init__.py +0 -0
  45. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/asynchronous/gather.py +0 -0
  46. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/__init__.py +0 -0
  47. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/artifacts.py +0 -0
  48. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/base.py +0 -0
  49. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/claude.py +0 -0
  50. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/gemini.py +0 -0
  51. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/gitlab.py +0 -0
  52. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/http.py +0 -0
  53. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/llm.py +0 -0
  54. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/logger.py +0 -0
  55. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/openai.py +0 -0
  56. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/prompt.py +0 -0
  57. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/review.py +0 -0
  58. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/config/vcs.py +0 -0
  59. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/constants/__init__.py +0 -0
  60. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/constants/llm_provider.py +0 -0
  61. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/constants/vcs_provider.py +0 -0
  62. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/diff/__init__.py +0 -0
  63. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/diff/models.py +0 -0
  64. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/diff/parser.py +0 -0
  65. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/diff/tools.py +0 -0
  66. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/__init__.py +0 -0
  67. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/client.py +0 -0
  68. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/event_hooks/__init__.py +0 -0
  69. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/event_hooks/base.py +0 -0
  70. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/event_hooks/logger.py +0 -0
  71. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/handlers.py +0 -0
  72. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/transports/__init__.py +0 -0
  73. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/http/transports/retry.py +0 -0
  74. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/logger.py +0 -0
  75. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/resources.py +0 -0
  76. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/template/__init__.py +0 -0
  77. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/libs/template/render.py +0 -0
  78. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/__init__.py +0 -0
  79. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_context.md +0 -0
  80. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_inline.md +0 -0
  81. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_summary.md +0 -0
  82. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_system_context.md +0 -0
  83. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_system_inline.md +0 -0
  84. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/prompts/default_system_summary.md +0 -0
  85. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/resources/__init__.py +0 -0
  86. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/resources/pricing.yaml +0 -0
  87. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/__init__.py +0 -0
  88. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/artifacts/__init__.py +0 -0
  89. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/artifacts/schema.py +0 -0
  90. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/artifacts/service.py +0 -0
  91. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/artifacts/tools.py +0 -0
  92. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/cost/__init__.py +0 -0
  93. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/cost/schema.py +0 -0
  94. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/cost/service.py +0 -0
  95. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/diff/__init__.py +0 -0
  96. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/diff/schema.py +0 -0
  97. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/diff/service.py +0 -0
  98. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/diff/tools.py +0 -0
  99. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/git/__init__.py +0 -0
  100. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/git/service.py +0 -0
  101. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/git/types.py +0 -0
  102. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/__init__.py +0 -0
  103. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/claude/__init__.py +0 -0
  104. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/claude/client.py +0 -0
  105. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/factory.py +0 -0
  106. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/gemini/__init__.py +0 -0
  107. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/gemini/client.py +0 -0
  108. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/openai/__init__.py +0 -0
  109. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/openai/client.py +0 -0
  110. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/llm/types.py +0 -0
  111. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/prompt/__init__.py +0 -0
  112. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/prompt/schema.py +0 -0
  113. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/prompt/service.py +0 -0
  114. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/__init__.py +0 -0
  115. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/inline/__init__.py +0 -0
  116. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/inline/schema.py +0 -0
  117. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/policy/__init__.py +0 -0
  118. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/policy/service.py +0 -0
  119. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/service.py +0 -0
  120. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/summary/__init__.py +0 -0
  121. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/summary/schema.py +0 -0
  122. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/review/summary/service.py +0 -0
  123. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/vcs/__init__.py +0 -0
  124. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/vcs/factory.py +0 -0
  125. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/vcs/gitlab/__init__.py +0 -0
  126. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/vcs/gitlab/client.py +0 -0
  127. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/services/vcs/types.py +0 -0
  128. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/__init__.py +0 -0
  129. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/fixtures/__init__.py +0 -0
  130. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/fixtures/git.py +0 -0
  131. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/__init__.py +0 -0
  132. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/__init__.py +0 -0
  133. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/claude/__init__.py +0 -0
  134. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/claude/test_client.py +0 -0
  135. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/claude/test_schema.py +0 -0
  136. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/gemini/__init__.py +0 -0
  137. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/gemini/test_client.py +0 -0
  138. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/gemini/test_schema.py +0 -0
  139. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/gitlab/__init__.py +0 -0
  140. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/gitlab/test_client.py +0 -0
  141. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/openai/__init__.py +0 -0
  142. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/openai/test_client.py +0 -0
  143. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/clients/openai/test_schema.py +0 -0
  144. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/__init__.py +0 -0
  145. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/config/__init__.py +0 -0
  146. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/config/test_prompt.py +0 -0
  147. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/diff/__init__.py +0 -0
  148. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/diff/test_models.py +0 -0
  149. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/diff/test_parser.py +0 -0
  150. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/diff/test_tools.py +0 -0
  151. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/template/__init__.py +0 -0
  152. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/libs/template/test_render.py +0 -0
  153. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/__init__.py +0 -0
  154. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/diff/__init__.py +0 -0
  155. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/diff/test_service.py +0 -0
  156. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/diff/test_tools.py +0 -0
  157. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/prompt/__init__.py +0 -0
  158. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/prompt/test_schema.py +0 -0
  159. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/prompt/test_service.py +0 -0
  160. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/__init__.py +0 -0
  161. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/inline/__init__.py +0 -0
  162. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/inline/test_schema.py +0 -0
  163. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/policy/__init__.py +0 -0
  164. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/policy/test_service.py +0 -0
  165. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/summary/__init__.py +0 -0
  166. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/summary/test_schema.py +0 -0
  167. {xai_review-0.7.0 → xai_review-0.10.0}/ai_review/tests/suites/services/review/summary/test_service.py +0 -0
  168. {xai_review-0.7.0 → xai_review-0.10.0}/setup.cfg +0 -0
  169. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/dependency_links.txt +0 -0
  170. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/entry_points.txt +0 -0
  171. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/requires.txt +0 -0
  172. {xai_review-0.7.0 → xai_review-0.10.0}/xai_review.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xai-review
3
- Version: 0.7.0
3
+ Version: 0.10.0
4
4
  Summary: AI-powered code review tool
5
5
  Author-email: Nikita Filonov <nikita.filonov@example.com>
6
6
  Maintainer-email: Nikita Filonov <nikita.filonov@example.com>
@@ -298,6 +298,6 @@ ai-review:
298
298
 
299
299
  ## 📂 Examples
300
300
 
301
- - [./docs/ci/gitlab.yaml](./docs/ci/gitlab.yaml) — ready-to-use CI snippet
301
+ - [./docs/ci](./docs/ci) — ready-to-use CI snippets
302
302
  - [./docs/configs](./docs/configs) — sample `.yaml`, `.json`, `.env` configs
303
303
  - [./docs/prompts](./docs/prompts) — prompt templates for Python/Go (light & strict modes)
@@ -274,6 +274,6 @@ ai-review:
274
274
 
275
275
  ## 📂 Examples
276
276
 
277
- - [./docs/ci/gitlab.yaml](./docs/ci/gitlab.yaml) — ready-to-use CI snippet
277
+ - [./docs/ci](./docs/ci) — ready-to-use CI snippets
278
278
  - [./docs/configs](./docs/configs) — sample `.yaml`, `.json`, `.env` configs
279
279
  - [./docs/prompts](./docs/prompts) — prompt templates for Python/Go (light & strict modes)
@@ -13,7 +13,7 @@ class GitLabHTTPClient:
13
13
 
14
14
 
15
15
  def get_gitlab_http_client() -> GitLabHTTPClient:
16
- logger = get_logger("GITLAB_MERGE_REQUESTS_HTTP_CLIENT")
16
+ logger = get_logger("GITLAB_HTTP_CLIENT")
17
17
  logger_event_hook = LoggerEventHook(logger=logger)
18
18
  retry_transport = RetryTransport(transport=AsyncHTTPTransport())
19
19
 
@@ -0,0 +1,19 @@
1
+ import re
2
+
3
+ CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F]")
4
+
5
+
6
+ def sanitize_json_string(raw: str) -> str:
7
+ def replace(match: re.Match) -> str:
8
+ char = match.group()
9
+ match char:
10
+ case "\n":
11
+ return "\\n"
12
+ case "\r":
13
+ return "\\r"
14
+ case "\t":
15
+ return "\\t"
16
+ case _:
17
+ return f"\\u{ord(char):04x}"
18
+
19
+ return CONTROL_CHARS_RE.sub(replace, raw)
@@ -13,7 +13,7 @@ Supported build modes:
13
13
  - ADDED_AND_REMOVED_WITH_CONTEXT added + removed + surrounding unchanged lines
14
14
  """
15
15
  from enum import Enum
16
- from typing import Iterable, Optional
16
+ from typing import Iterable
17
17
 
18
18
  from ai_review.libs.diff.models import DiffFile, DiffLineType
19
19
  from ai_review.services.diff.tools import normalize_file_path, marker_for_line, read_snapshot
@@ -24,7 +24,7 @@ class MarkerType(Enum):
24
24
  REMOVED = "removed"
25
25
 
26
26
 
27
- def build_full_file_current(file: Optional[DiffFile], file_path: str, head_sha: str | None) -> str:
27
+ def build_full_file_current(file: DiffFile | None, file_path: str, head_sha: str | None) -> str:
28
28
  text = read_snapshot(file_path, head_sha=head_sha)
29
29
  if text is None:
30
30
  return f"# Failed to read current snapshot for {file_path}"
@@ -33,7 +33,7 @@ def build_full_file_current(file: Optional[DiffFile], file_path: str, head_sha:
33
33
  return render_plain_numbered(text.splitlines(), added_new, marker_type=MarkerType.ADDED)
34
34
 
35
35
 
36
- def build_full_file_previous(file: Optional[DiffFile], file_path: str, base_sha: str | None) -> str:
36
+ def build_full_file_previous(file: DiffFile | None, file_path: str, base_sha: str | None) -> str:
37
37
  text = read_snapshot(file_path, base_sha=base_sha)
38
38
  if text is None:
39
39
  return f"# Failed to read previous snapshot for {file_path} (base_sha missing or file absent)"
@@ -42,31 +42,31 @@ def build_full_file_previous(file: Optional[DiffFile], file_path: str, base_sha:
42
42
  return render_plain_numbered(text.splitlines(), removed_old, marker_type=MarkerType.REMOVED)
43
43
 
44
44
 
45
- def build_full_file_diff(file: DiffFile) -> str:
45
+ def build_full_file_diff(file: DiffFile | None) -> str:
46
46
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=0)
47
47
 
48
48
 
49
- def build_only_added(file: DiffFile) -> str:
49
+ def build_only_added(file: DiffFile | None) -> str:
50
50
  return render_unified(file, include_added=True, include_removed=False, include_unchanged=False, context=0)
51
51
 
52
52
 
53
- def build_only_removed(file: DiffFile) -> str:
53
+ def build_only_removed(file: DiffFile | None) -> str:
54
54
  return render_unified(file, include_added=False, include_removed=True, include_unchanged=False, context=0)
55
55
 
56
56
 
57
- def build_added_and_removed(file: DiffFile) -> str:
57
+ def build_added_and_removed(file: DiffFile | None) -> str:
58
58
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=False, context=0)
59
59
 
60
60
 
61
- def build_only_added_with_context(file: DiffFile, context: int) -> str:
61
+ def build_only_added_with_context(file: DiffFile | None, context: int) -> str:
62
62
  return render_unified(file, include_added=True, include_removed=False, include_unchanged=True, context=context)
63
63
 
64
64
 
65
- def build_only_removed_with_context(file: DiffFile, context: int) -> str:
65
+ def build_only_removed_with_context(file: DiffFile | None, context: int) -> str:
66
66
  return render_unified(file, include_added=False, include_removed=True, include_unchanged=True, context=context)
67
67
 
68
68
 
69
- def build_added_and_removed_with_context(file: DiffFile, context: int) -> str:
69
+ def build_added_and_removed_with_context(file: DiffFile | None, context: int) -> str:
70
70
  return render_unified(file, include_added=True, include_removed=True, include_unchanged=True, context=context)
71
71
 
72
72
 
@@ -87,7 +87,7 @@ def render_plain_numbered(lines: Iterable[str], changed: set[int], marker_type:
87
87
 
88
88
 
89
89
  def render_unified(
90
- file: DiffFile,
90
+ file: DiffFile | None,
91
91
  *,
92
92
  include_added: bool,
93
93
  include_removed: bool,
@@ -104,12 +104,19 @@ def render_unified(
104
104
 
105
105
  Context controls how many unchanged lines around modifications are shown.
106
106
  """
107
+ if file is None:
108
+ return "# Diff target not found"
109
+
110
+ if not file.hunks:
111
+ header = normalize_file_path(file.new_name or file.orig_name)
112
+ return f"# No matching lines for mode in {header}"
113
+
107
114
  lines_out: list[str] = []
108
115
 
109
116
  added_new_positions = file.added_line_numbers()
110
117
  removed_old_positions = file.removed_line_numbers()
111
118
 
112
- def in_context(old_no: Optional[int], new_no: Optional[int]) -> bool:
119
+ def in_context(old_no: int | None, new_no: int | None) -> bool:
113
120
  """Check if an unchanged line falls within context radius."""
114
121
  if context <= 0:
115
122
  return False
@@ -12,7 +12,7 @@ def build_prompt_context_from_mr_info(mr: MRInfoSchema) -> PromptContextSchema:
12
12
 
13
13
  merge_request_reviewers=[reviewer.name for reviewer in mr.reviewers],
14
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,
15
+ merge_request_reviewer=mr.reviewers[0].name if mr.reviewers else "",
16
16
 
17
17
  merge_request_assignees=[assignee.name for assignee in mr.assignees],
18
18
  merge_request_assignees_usernames=[assignee.username for assignee in mr.assignees],
@@ -0,0 +1,53 @@
1
+ import re
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from ai_review.libs.json import sanitize_json_string
6
+ from ai_review.libs.logger import get_logger
7
+ from ai_review.services.review.inline.schema import InlineCommentListSchema
8
+
9
+ logger = get_logger("INLINE_COMMENT_SERVICE")
10
+
11
+ FIRST_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*]", re.MULTILINE)
12
+ CLEAN_JSON_BLOCK_RE = re.compile(r"```(?:json)?(.*?)```", re.DOTALL | re.IGNORECASE)
13
+
14
+
15
+ class InlineCommentService:
16
+ @classmethod
17
+ def try_parse_model_output(cls, raw: str) -> InlineCommentListSchema | None:
18
+ try:
19
+ return InlineCommentListSchema.model_validate_json(raw)
20
+ except ValidationError as error:
21
+ logger.debug(f"Parse failed, trying sanitized JSON: {raw[:200]=}, {error=}")
22
+ try:
23
+ cleaned = sanitize_json_string(raw)
24
+ return InlineCommentListSchema.model_validate_json(cleaned)
25
+ except ValidationError as error:
26
+ logger.debug(f"Sanitized JSON still invalid: {raw[:200]=}, {error=}")
27
+ return None
28
+
29
+ @classmethod
30
+ def parse_model_output(cls, output: str) -> InlineCommentListSchema:
31
+ output = (output or "").strip()
32
+ if not output:
33
+ logger.warning("️LLM returned empty string for inline review")
34
+ return InlineCommentListSchema(root=[])
35
+
36
+ if match := CLEAN_JSON_BLOCK_RE.search(output):
37
+ output = match.group(1).strip()
38
+
39
+ if parsed := cls.try_parse_model_output(output):
40
+ return parsed
41
+
42
+ logger.warning("Failed to parse LLM output as JSON, trying to extract first JSON array...")
43
+
44
+ if json_array_match := FIRST_JSON_ARRAY_RE.search(output):
45
+ if parsed := cls.try_parse_model_output(json_array_match.group(0)):
46
+ logger.info("Successfully parsed JSON after extracting array from output")
47
+ return parsed
48
+ else:
49
+ logger.error("Extracted JSON array is still invalid after sanitization")
50
+ else:
51
+ logger.error("No JSON array found in LLM output")
52
+
53
+ return InlineCommentListSchema(root=[])
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from ai_review.libs.json import sanitize_json_string
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ ("actual", "expected"),
8
+ [
9
+ ("hello world", "hello world"),
10
+ ("line1\nline2", "line1\\nline2"),
11
+ ("foo\rbar", "foo\\rbar"),
12
+ ("a\tb", "a\\tb"),
13
+ ("abc\0def", "abc\\u0000def"),
14
+ ("x\n\ry\t\0z", "x\\n\\ry\\t\\u0000z"),
15
+ ],
16
+ )
17
+ def test_sanitize_basic_cases(actual: str, expected: str) -> None:
18
+ assert sanitize_json_string(actual) == expected
19
+
20
+
21
+ def test_sanitize_multiple_control_chars() -> None:
22
+ raw = "A\nB\rC\tD\0E"
23
+ result = sanitize_json_string(raw)
24
+ assert result == "A\\nB\\rC\\tD\\u0000E"
25
+
26
+
27
+ def test_sanitize_idempotent() -> None:
28
+ raw = "foo\nbar"
29
+ once = sanitize_json_string(raw)
30
+ twice = sanitize_json_string(once)
31
+ assert once == twice
32
+
33
+
34
+ def test_sanitize_empty_string() -> None:
35
+ assert sanitize_json_string("") == ""
36
+
37
+
38
+ def test_sanitize_only_control_chars() -> None:
39
+ raw = "\n\r\t\0"
40
+ result = sanitize_json_string(raw)
41
+ assert result == "\\n\\r\\t\\u0000"
@@ -166,3 +166,26 @@ def test_build_added_and_removed_with_context(sample_diff_file: DiffFile) -> Non
166
166
  " 2: keep B\n"
167
167
  "+3: added me # added"
168
168
  )
169
+
170
+
171
+ def test_build_full_file_diff_empty_file() -> None:
172
+ """
173
+ Should handle new empty file (mode=NEW, no hunks).
174
+ """
175
+ file = DiffFile(
176
+ header="diff --git a/LICENSE b/LICENSE",
177
+ mode=FileMode.NEW,
178
+ orig_name="",
179
+ new_name="LICENSE",
180
+ hunks=[],
181
+ )
182
+ out = renderers.build_full_file_diff(file)
183
+ assert "New empty file: LICENSE" in out or "No matching lines" in out
184
+
185
+
186
+ def test_build_full_file_diff_none() -> None:
187
+ """
188
+ Should handle case when diff target is None.
189
+ """
190
+ out = renderers.build_full_file_diff(None)
191
+ assert "Diff target not found" in out or out == ""
@@ -0,0 +1,101 @@
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 == []
50
+
51
+
52
+ def test_json_with_raw_newline_sanitized():
53
+ output = '[{"file": "e.py", "line": 3, "message": "line1\nline2"}]'
54
+ result = InlineCommentService.parse_model_output(output)
55
+ assert len(result.root) == 1
56
+ assert result.root[0].message == "line1\nline2"
57
+
58
+
59
+ def test_json_with_tab_character_sanitized():
60
+ output = '[{"file": "f.py", "line": 4, "message": "a\tb"}]'
61
+ result = InlineCommentService.parse_model_output(output)
62
+ assert len(result.root) == 1
63
+ assert result.root[0].message == "a\tb"
64
+
65
+
66
+ def test_json_with_null_byte_sanitized():
67
+ raw = "abc\0def"
68
+ output = f'[{{"file": "g.py", "line": 5, "message": "{raw}"}}]'
69
+ result = InlineCommentService.parse_model_output(output)
70
+ assert len(result.root) == 1
71
+ assert result.root[0].message == "abc\0def"
72
+
73
+
74
+ def test_json_with_multiple_control_chars():
75
+ raw = "x\n\ry\t\0z"
76
+ output = f'[{{"file": "h.py", "line": 6, "message": "{raw}"}}]'
77
+ result = InlineCommentService.parse_model_output(output)
78
+ assert len(result.root) == 1
79
+ assert result.root[0].message == "x\n\ry\t\0z"
80
+
81
+
82
+ def test_try_parse_valid_json():
83
+ raw = '[{"file": "ok.py", "line": 1, "message": "all good"}]'
84
+ result = InlineCommentService.try_parse_model_output(raw)
85
+ assert isinstance(result, InlineCommentListSchema)
86
+ assert len(result.root) == 1
87
+ assert result.root[0].file == "ok.py"
88
+
89
+
90
+ def test_try_parse_needs_sanitization():
91
+ raw = '[{"file": "bad.py", "line": 2, "message": "line1\nline2"}]'
92
+ result = InlineCommentService.try_parse_model_output(raw)
93
+ assert result is not None
94
+ assert result.root[0].file == "bad.py"
95
+ assert "line1" in result.root[0].message
96
+
97
+
98
+ def test_try_parse_totally_invalid_returns_none():
99
+ raw = "this is not json at all"
100
+ result = InlineCommentService.try_parse_model_output(raw)
101
+ assert result is None
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "xai-review"
7
7
  readme = "README.md"
8
- version = "0.7.0"
8
+ version = "0.10.0"
9
9
  license = { text = "Apache-2.0" }
10
10
  authors = [
11
11
  { name = "Nikita Filonov", email = "nikita.filonov@example.com" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xai-review
3
- Version: 0.7.0
3
+ Version: 0.10.0
4
4
  Summary: AI-powered code review tool
5
5
  Author-email: Nikita Filonov <nikita.filonov@example.com>
6
6
  Maintainer-email: Nikita Filonov <nikita.filonov@example.com>
@@ -298,6 +298,6 @@ ai-review:
298
298
 
299
299
  ## 📂 Examples
300
300
 
301
- - [./docs/ci/gitlab.yaml](./docs/ci/gitlab.yaml) — ready-to-use CI snippet
301
+ - [./docs/ci](./docs/ci) — ready-to-use CI snippets
302
302
  - [./docs/configs](./docs/configs) — sample `.yaml`, `.json`, `.env` configs
303
303
  - [./docs/prompts](./docs/prompts) — prompt templates for Python/Go (light & strict modes)
@@ -29,6 +29,7 @@ ai_review/clients/openai/__init__.py
29
29
  ai_review/clients/openai/client.py
30
30
  ai_review/clients/openai/schema.py
31
31
  ai_review/libs/__init__.py
32
+ ai_review/libs/json.py
32
33
  ai_review/libs/logger.py
33
34
  ai_review/libs/resources.py
34
35
  ai_review/libs/asynchronous/__init__.py
@@ -133,6 +134,7 @@ ai_review/tests/suites/clients/openai/__init__.py
133
134
  ai_review/tests/suites/clients/openai/test_client.py
134
135
  ai_review/tests/suites/clients/openai/test_schema.py
135
136
  ai_review/tests/suites/libs/__init__.py
137
+ ai_review/tests/suites/libs/test_json.py
136
138
  ai_review/tests/suites/libs/config/__init__.py
137
139
  ai_review/tests/suites/libs/config/test_prompt.py
138
140
  ai_review/tests/suites/libs/diff/__init__.py
@@ -1,38 +0,0 @@
1
- import re
2
-
3
- from pydantic import ValidationError
4
-
5
- from ai_review.libs.logger import get_logger
6
- from ai_review.services.review.inline.schema import InlineCommentListSchema
7
-
8
- logger = get_logger("INLINE_COMMENT_SERVICE")
9
-
10
- FIRST_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*]", re.MULTILINE)
11
- CLEAN_JSON_BLOCK_RE = re.compile(r"```(?:json)?(.*?)```", re.DOTALL | re.IGNORECASE)
12
-
13
-
14
- class InlineCommentService:
15
- @classmethod
16
- def parse_model_output(cls, output: str) -> InlineCommentListSchema:
17
- output = (output or "").strip()
18
- if not output:
19
- logger.warning("️LLM returned empty string for inline review")
20
- return InlineCommentListSchema(root=[])
21
-
22
- if match := CLEAN_JSON_BLOCK_RE.search(output):
23
- output = match.group(1).strip()
24
-
25
- try:
26
- return InlineCommentListSchema.model_validate_json(output)
27
- except ValidationError:
28
- logger.warning("LLM output is not valid JSON, trying to extract first JSON array...")
29
-
30
- if json_array_match := FIRST_JSON_ARRAY_RE.search(output):
31
- try:
32
- return InlineCommentListSchema.model_validate_json(json_array_match.group(0))
33
- except ValidationError:
34
- logger.exception("JSON array found but still invalid")
35
- else:
36
- logger.exception("No JSON array found in LLM output")
37
-
38
- return InlineCommentListSchema(root=[])
@@ -1,49 +0,0 @@
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 == []
File without changes
File without changes