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
ai_review/config.py ADDED
@@ -0,0 +1,62 @@
1
+ from pydantic_settings import (
2
+ BaseSettings,
3
+ SettingsConfigDict,
4
+ YamlConfigSettingsSource,
5
+ JsonConfigSettingsSource,
6
+ PydanticBaseSettingsSource
7
+ )
8
+
9
+ from ai_review.libs.config.artifacts import ArtifactsConfig
10
+ from ai_review.libs.config.base import (
11
+ get_env_config_file_or_default,
12
+ get_yaml_config_file_or_default,
13
+ get_json_config_file_or_default
14
+ )
15
+ from ai_review.libs.config.llm import LLMConfig
16
+ from ai_review.libs.config.logger import LoggerConfig
17
+ from ai_review.libs.config.prompt import PromptConfig
18
+ from ai_review.libs.config.review import ReviewConfig
19
+ from ai_review.libs.config.vcs import VCSConfig
20
+
21
+
22
+ class Settings(BaseSettings):
23
+ model_config = SettingsConfigDict(
24
+ extra='allow',
25
+
26
+ env_file=get_env_config_file_or_default(),
27
+ env_file_encoding="utf-8",
28
+ env_nested_delimiter="__",
29
+
30
+ yaml_file=get_yaml_config_file_or_default(),
31
+ yaml_file_encoding="utf-8",
32
+
33
+ json_file=get_json_config_file_or_default(),
34
+ json_file_encoding="utf-8"
35
+ )
36
+
37
+ llm: LLMConfig
38
+ vcs: VCSConfig
39
+ prompt: PromptConfig = PromptConfig()
40
+ review: ReviewConfig = ReviewConfig()
41
+ logger: LoggerConfig = LoggerConfig()
42
+ artifacts: ArtifactsConfig = ArtifactsConfig()
43
+
44
+ @classmethod
45
+ def settings_customise_sources(
46
+ cls,
47
+ settings_cls: type[BaseSettings],
48
+ init_settings: PydanticBaseSettingsSource,
49
+ env_settings: PydanticBaseSettingsSource,
50
+ dotenv_settings: PydanticBaseSettingsSource,
51
+ file_secret_settings: PydanticBaseSettingsSource,
52
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
53
+ return (
54
+ YamlConfigSettingsSource(cls),
55
+ JsonConfigSettingsSource(cls),
56
+ env_settings,
57
+ dotenv_settings,
58
+ init_settings,
59
+ )
60
+
61
+
62
+ settings = Settings()
File without changes
File without changes
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+ from typing import Awaitable, Iterable, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ async def bounded_gather(coroutines: Iterable[Awaitable[T]], concurrency: int = 32) -> tuple[T, ...]:
8
+ sem = asyncio.Semaphore(concurrency)
9
+
10
+ async def wrap(coro: Awaitable[T]) -> T:
11
+ async with sem:
12
+ return await coro
13
+
14
+ return await asyncio.gather(*(wrap(coroutine) for coroutine in coroutines), return_exceptions=True)
File without changes
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, DirectoryPath, field_validator, Field
2
+
3
+
4
+ class ArtifactsConfig(BaseModel):
5
+ llm_dir: DirectoryPath = Field(default=DirectoryPath("./artifacts/llm"), validate_default=True)
6
+ llm_enabled: bool = False
7
+
8
+ @field_validator('llm_dir', mode='before')
9
+ def validate_directories(cls, value: DirectoryPath | str) -> DirectoryPath:
10
+ directory = DirectoryPath(value)
11
+ directory.mkdir(parents=True, exist_ok=True)
12
+ return directory
@@ -0,0 +1,24 @@
1
+ import os
2
+ from enum import StrEnum
3
+
4
+
5
+ class ConfigEnv(StrEnum):
6
+ ENV = "AI_REVIEW_CONFIG_FILE_ENV"
7
+ YAML = "AI_REVIEW_CONFIG_FILE_YAML"
8
+ JSON = "AI_REVIEW_CONFIG_FILE_JSON"
9
+
10
+
11
+ def get_config_file_or_default(variable: str, default_filename: str) -> str:
12
+ return os.getenv(variable, os.path.join(os.getcwd(), default_filename))
13
+
14
+
15
+ def get_env_config_file_or_default() -> str:
16
+ return get_config_file_or_default(ConfigEnv.ENV, ".env")
17
+
18
+
19
+ def get_yaml_config_file_or_default() -> str:
20
+ return get_config_file_or_default(ConfigEnv.YAML, ".ai-review.yaml")
21
+
22
+
23
+ def get_json_config_file_or_default() -> str:
24
+ return get_config_file_or_default(ConfigEnv.JSON, ".ai-review.json")
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ai_review.libs.config.http import HTTPClientConfig
4
+
5
+
6
+ class ClaudeMetaConfig(BaseModel):
7
+ model: str = "claude-3-sonnet"
8
+ max_tokens: int = 1200
9
+ temperature: float = 0.3
10
+
11
+
12
+ class ClaudeHTTPClientConfig(HTTPClientConfig):
13
+ api_version: str = "2023-06-01"
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ai_review.libs.config.http import HTTPClientConfig
4
+
5
+
6
+ class GeminiMetaConfig(BaseModel):
7
+ model: str = "gemini-2.0-pro"
8
+ max_tokens: int = 1200
9
+ temperature: float = 0.3
10
+
11
+
12
+ class GeminiHTTPClientConfig(HTTPClientConfig):
13
+ pass
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ai_review.libs.config.http import HTTPClientConfig
4
+
5
+
6
+ class GitLabPipelineConfig(BaseModel):
7
+ project_id: str
8
+ merge_request_id: str
9
+
10
+
11
+ class GitLabHTTPClientConfig(HTTPClientConfig):
12
+ pass
@@ -0,0 +1,19 @@
1
+ from pydantic import BaseModel, HttpUrl, SecretStr
2
+
3
+
4
+ class HTTPClientConfig(BaseModel):
5
+ timeout: float = 120
6
+ api_url: HttpUrl
7
+ api_token: SecretStr
8
+
9
+ @property
10
+ def api_key(self) -> str:
11
+ return self.api_token.get_secret_value()
12
+
13
+ @property
14
+ def base_url(self) -> str:
15
+ return str(self.api_url)
16
+
17
+ @property
18
+ def bearer_token(self) -> str:
19
+ return self.api_token.get_secret_value()
@@ -0,0 +1,61 @@
1
+ from functools import cached_property
2
+ from typing import Annotated, Literal
3
+
4
+ import yaml
5
+ from pydantic import BaseModel, Field, FilePath
6
+
7
+ from ai_review.libs.config.claude import ClaudeHTTPClientConfig, ClaudeMetaConfig
8
+ from ai_review.libs.config.gemini import GeminiHTTPClientConfig, GeminiMetaConfig
9
+ from ai_review.libs.config.openai import OpenAIHTTPClientConfig, OpenAIMetaConfig
10
+ from ai_review.libs.constants.llm_provider import LLMProvider
11
+ from ai_review.libs.resources import load_resource
12
+
13
+
14
+ class LLMPricingConfig(BaseModel):
15
+ input: float
16
+ output: float
17
+
18
+
19
+ class LLMConfigBase(BaseModel):
20
+ provider: LLMProvider
21
+ pricing_file: FilePath | None = None
22
+
23
+ @cached_property
24
+ def pricing_file_or_default(self):
25
+ if self.pricing_file and self.pricing_file.exists():
26
+ return self.pricing_file
27
+
28
+ return load_resource(
29
+ package="ai_review.resources",
30
+ filename="pricing.yaml",
31
+ fallback="ai_review/resources/pricing.yaml"
32
+ )
33
+
34
+ def load_pricing(self) -> dict[str, LLMPricingConfig]:
35
+ data = self.pricing_file_or_default.read_text(encoding="utf-8")
36
+ raw = yaml.safe_load(data)
37
+ return {model: LLMPricingConfig(**values) for model, values in raw.items()}
38
+
39
+
40
+ class OpenAILLMConfig(LLMConfigBase):
41
+ meta: OpenAIMetaConfig
42
+ provider: Literal[LLMProvider.OPENAI]
43
+ http_client: OpenAIHTTPClientConfig
44
+
45
+
46
+ class GeminiLLMConfig(LLMConfigBase):
47
+ meta: GeminiMetaConfig
48
+ provider: Literal[LLMProvider.GEMINI]
49
+ http_client: GeminiHTTPClientConfig
50
+
51
+
52
+ class ClaudeLLMConfig(LLMConfigBase):
53
+ meta: ClaudeMetaConfig
54
+ provider: Literal[LLMProvider.CLAUDE]
55
+ http_client: ClaudeHTTPClientConfig
56
+
57
+
58
+ LLMConfig = Annotated[
59
+ OpenAILLMConfig | GeminiLLMConfig | ClaudeLLMConfig,
60
+ Field(discriminator="provider")
61
+ ]
@@ -0,0 +1,17 @@
1
+ from enum import StrEnum
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class LoggerLevel(StrEnum):
7
+ NOTSET = "NOTSET"
8
+ DEBUG = "DEBUG"
9
+ INFO = "INFO"
10
+ WARNING = "WARNING"
11
+ ERROR = "ERROR"
12
+ CRITICAL = "CRITICAL"
13
+
14
+
15
+ class LoggerConfig(BaseModel):
16
+ level: LoggerLevel = LoggerLevel.INFO
17
+ format: str = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {extra[logger_name]} | {message}"
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ai_review.libs.config.http import HTTPClientConfig
4
+
5
+
6
+ class OpenAIMetaConfig(BaseModel):
7
+ model: str = "gpt-4o-mini"
8
+ max_tokens: int = 1200
9
+ temperature: float = 0.3
10
+
11
+
12
+ class OpenAIHTTPClientConfig(HTTPClientConfig):
13
+ pass
@@ -0,0 +1,121 @@
1
+ from functools import cached_property
2
+ from pathlib import Path
3
+
4
+ from pydantic import BaseModel, FilePath, Field
5
+
6
+ from ai_review.libs.resources import load_resource
7
+
8
+
9
+ class PromptConfig(BaseModel):
10
+ context: dict[str, str] = Field(default_factory=dict)
11
+ inline_prompt_files: list[FilePath] | None = None
12
+ context_prompt_files: list[FilePath] | None = None
13
+ summary_prompt_files: list[FilePath] | None = None
14
+ system_inline_prompt_files: list[FilePath] | None = None
15
+ system_context_prompt_files: list[FilePath] | None = None
16
+ system_summary_prompt_files: list[FilePath] | None = None
17
+ include_inline_system_prompts: bool = True
18
+ include_context_system_prompts: bool = True
19
+ include_summary_system_prompts: bool = True
20
+
21
+ @cached_property
22
+ def inline_prompt_files_or_default(self) -> list[Path]:
23
+ return self.inline_prompt_files or [
24
+ load_resource(
25
+ package="ai_review.prompts",
26
+ filename="default_inline.md",
27
+ fallback="ai_review/prompts/default_inline.md"
28
+ )
29
+ ]
30
+
31
+ @cached_property
32
+ def context_prompt_files_or_default(self) -> list[Path]:
33
+ return self.context_prompt_files or [
34
+ load_resource(
35
+ package="ai_review.prompts",
36
+ filename="default_context.md",
37
+ fallback="ai_review/prompts/default_context.md"
38
+ )
39
+ ]
40
+
41
+ @cached_property
42
+ def summary_prompt_files_or_default(self) -> list[Path]:
43
+ return self.summary_prompt_files or [
44
+ load_resource(
45
+ package="ai_review.prompts",
46
+ filename="default_summary.md",
47
+ fallback="ai_review/prompts/default_summary.md"
48
+ )
49
+ ]
50
+
51
+ @cached_property
52
+ def system_inline_prompt_files_or_default(self) -> list[Path]:
53
+ global_files = [
54
+ load_resource(
55
+ package="ai_review.prompts",
56
+ filename="default_system_inline.md",
57
+ fallback="ai_review/prompts/default_system_inline.md"
58
+ )
59
+ ]
60
+
61
+ if self.system_inline_prompt_files is None:
62
+ return global_files
63
+
64
+ if self.include_inline_system_prompts:
65
+ return global_files + self.system_inline_prompt_files
66
+
67
+ return self.system_inline_prompt_files
68
+
69
+ @cached_property
70
+ def system_context_prompt_files_or_default(self) -> list[Path]:
71
+ global_files = [
72
+ load_resource(
73
+ package="ai_review.prompts",
74
+ filename="default_system_context.md",
75
+ fallback="ai_review/prompts/default_system_context.md"
76
+ )
77
+ ]
78
+
79
+ if self.system_context_prompt_files is None:
80
+ return global_files
81
+
82
+ if self.include_context_system_prompts:
83
+ return global_files + self.system_context_prompt_files
84
+
85
+ return self.system_context_prompt_files
86
+
87
+ @cached_property
88
+ def system_summary_prompt_files_or_default(self) -> list[Path]:
89
+ global_files = [
90
+ load_resource(
91
+ package="ai_review.prompts",
92
+ filename="default_system_summary.md",
93
+ fallback="ai_review/prompts/default_system_summary.md"
94
+ )
95
+ ]
96
+
97
+ if self.system_summary_prompt_files is None:
98
+ return global_files
99
+
100
+ if self.include_summary_system_prompts:
101
+ return global_files + self.system_summary_prompt_files
102
+
103
+ return self.system_summary_prompt_files
104
+
105
+ def load_inline(self) -> list[str]:
106
+ return [file.read_text(encoding="utf-8") for file in self.inline_prompt_files_or_default]
107
+
108
+ def load_context(self) -> list[str]:
109
+ return [file.read_text(encoding="utf-8") for file in self.context_prompt_files_or_default]
110
+
111
+ def load_summary(self) -> list[str]:
112
+ return [file.read_text(encoding="utf-8") for file in self.summary_prompt_files_or_default]
113
+
114
+ def load_system_inline(self) -> list[str]:
115
+ return [file.read_text(encoding="utf-8") for file in self.system_inline_prompt_files_or_default]
116
+
117
+ def load_system_context(self) -> list[str]:
118
+ return [file.read_text(encoding="utf-8") for file in self.system_context_prompt_files_or_default]
119
+
120
+ def load_system_summary(self) -> list[str]:
121
+ return [file.read_text(encoding="utf-8") for file in self.system_summary_prompt_files_or_default]
@@ -0,0 +1,30 @@
1
+ from enum import StrEnum
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ReviewMode(StrEnum):
7
+ FULL_FILE_DIFF = "FULL_FILE_DIFF"
8
+ FULL_FILE_CURRENT = "FULL_FILE_CURRENT"
9
+ FULL_FILE_PREVIOUS = "FULL_FILE_PREVIOUS"
10
+
11
+ ONLY_ADDED = "ONLY_ADDED"
12
+ ONLY_REMOVED = "ONLY_REMOVED"
13
+ ADDED_AND_REMOVED = "ADDED_AND_REMOVED"
14
+
15
+ ONLY_ADDED_WITH_CONTEXT = "ONLY_ADDED_WITH_CONTEXT"
16
+ ONLY_REMOVED_WITH_CONTEXT = "ONLY_REMOVED_WITH_CONTEXT"
17
+ ADDED_AND_REMOVED_WITH_CONTEXT = "ADDED_AND_REMOVED_WITH_CONTEXT"
18
+
19
+
20
+ class ReviewConfig(BaseModel):
21
+ mode: ReviewMode = ReviewMode.FULL_FILE_DIFF
22
+ inline_tag: str = Field(default="#ai-review-inline")
23
+ summary_tag: str = Field(default="#ai-review-summary")
24
+ context_lines: int = Field(default=10, ge=0)
25
+ allow_changes: list[str] = Field(default_factory=list)
26
+ ignore_changes: list[str] = Field(default_factory=list)
27
+ review_added_marker: str = " # added"
28
+ review_removed_marker: str = " # removed"
29
+ max_inline_comments: int | None = None
30
+ max_context_comments: int | None = None
@@ -0,0 +1,19 @@
1
+ from typing import Annotated, Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ai_review.libs.config.gitlab import GitLabPipelineConfig, GitLabHTTPClientConfig
6
+ from ai_review.libs.constants.vcs_provider import VCSProvider
7
+
8
+
9
+ class VCSConfigBase(BaseModel):
10
+ provider: VCSProvider
11
+
12
+
13
+ class GitLabVCSConfig(VCSConfigBase):
14
+ provider: Literal[VCSProvider.GITLAB]
15
+ pipeline: GitLabPipelineConfig
16
+ http_client: GitLabHTTPClientConfig
17
+
18
+
19
+ VCSConfig = Annotated[GitLabVCSConfig, Field(discriminator="provider")]
File without changes
@@ -0,0 +1,7 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class LLMProvider(StrEnum):
5
+ OPENAI = "OPENAI"
6
+ GEMINI = "GEMINI"
7
+ CLAUDE = "CLAUDE"
@@ -0,0 +1,6 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class VCSProvider(StrEnum):
5
+ GITHUB = "GITHUB"
6
+ GITLAB = "GITLAB"
File without changes
@@ -0,0 +1,100 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum, auto
3
+ from typing import List
4
+
5
+
6
+ class FileMode(Enum):
7
+ DELETED = auto()
8
+ MODIFIED = auto()
9
+ NEW = auto()
10
+
11
+
12
+ class DiffLineType(Enum):
13
+ ADDED = auto()
14
+ REMOVED = auto()
15
+ UNCHANGED = auto()
16
+
17
+
18
+ @dataclass
19
+ class DiffLine:
20
+ type: DiffLineType
21
+ number: int | None
22
+ content: str
23
+ position: int
24
+
25
+
26
+ @dataclass
27
+ class DiffRange:
28
+ start: int
29
+ length: int
30
+ lines: List[DiffLine]
31
+
32
+
33
+ @dataclass
34
+ class DiffHunk:
35
+ header: str
36
+ orig_range: DiffRange
37
+ new_range: DiffRange
38
+ lines: List[DiffLine]
39
+
40
+
41
+ @dataclass
42
+ class DiffFile:
43
+ header: str
44
+ mode: FileMode
45
+ orig_name: str
46
+ new_name: str
47
+ hunks: List[DiffHunk]
48
+
49
+ def added_new_lines(self) -> list[DiffLine]:
50
+ return [
51
+ line
52
+ for hunk in self.hunks
53
+ for line in hunk.new_range.lines
54
+ if line.type is DiffLineType.ADDED
55
+ ]
56
+
57
+ def removed_old_lines(self) -> list[DiffLine]:
58
+ return [
59
+ line
60
+ for hunk in self.hunks
61
+ for line in hunk.orig_range.lines
62
+ if line.type is DiffLineType.REMOVED
63
+ ]
64
+
65
+ def added_line_numbers(self) -> set[int]:
66
+ return {line.number for line in self.added_new_lines() if line.number is not None}
67
+
68
+ def removed_line_numbers(self) -> set[int]:
69
+ return {line.number for line in self.removed_old_lines() if line.number is not None}
70
+
71
+
72
+ @dataclass
73
+ class Diff:
74
+ files: List[DiffFile]
75
+ raw: str
76
+
77
+ def summary(self) -> str:
78
+ parts = []
79
+ for file in self.files:
80
+ parts.append(f"{file.mode.name} {file.new_name or file.orig_name}")
81
+ for hunk in file.hunks:
82
+ parts.append(f" Hunk: {hunk.header} ({len(hunk.lines)} lines)")
83
+
84
+ return "\n".join(parts)
85
+
86
+ def changed_lines(self) -> dict[str, list[int]]:
87
+ result: dict[str, list[int]] = {}
88
+ for file in self.files:
89
+ if file.mode == FileMode.DELETED:
90
+ continue
91
+
92
+ result[file.new_name] = [
93
+ line.number for h in file.hunks for line in h.new_range.lines
94
+ if line.type == DiffLineType.ADDED
95
+ ]
96
+
97
+ return result
98
+
99
+ def changed_files(self) -> list[str]:
100
+ return [file.new_name for file in self.files if file.mode != FileMode.DELETED]
@@ -0,0 +1,111 @@
1
+ import re
2
+
3
+ from ai_review.libs.diff.models import (
4
+ Diff,
5
+ DiffFile,
6
+ DiffHunk,
7
+ DiffLine,
8
+ DiffLineType,
9
+ DiffRange,
10
+ FileMode,
11
+ )
12
+ from ai_review.libs.diff.tools import is_source_line, get_line_type
13
+
14
+ HUNK_RE = re.compile(r"@@ -(\d+),?(\d+)? \+(\d+),?(\d+)? @@ ?(.*)?")
15
+ OLD_FILE_PREFIX = "--- a/"
16
+ NEW_FILE_PREFIX = "+++ b/"
17
+
18
+
19
+ class DiffParser:
20
+ @classmethod
21
+ def parse(cls, diff_string: str) -> Diff:
22
+ lines = diff_string.splitlines()
23
+ files: list[DiffFile] = []
24
+
25
+ current_file: DiffFile | None = None
26
+ current_hunk: DiffHunk | None = None
27
+
28
+ added_count = removed_count = 0
29
+ diff_pos = 0
30
+
31
+ for raw in lines:
32
+ diff_pos += 1
33
+
34
+ # Начало нового файла
35
+ if raw.startswith("diff "):
36
+ current_file = DiffFile(
37
+ header=raw,
38
+ mode=FileMode.MODIFIED,
39
+ orig_name="",
40
+ new_name="",
41
+ hunks=[],
42
+ )
43
+ files.append(current_file)
44
+ continue
45
+
46
+ # Дополняем header файла
47
+ if raw.startswith("index ") or raw.startswith("--- ") or raw.startswith("+++ "):
48
+ current_file.header += "\n" + raw
49
+
50
+ if raw.startswith(OLD_FILE_PREFIX):
51
+ current_file.orig_name = raw[len(OLD_FILE_PREFIX):]
52
+ continue
53
+
54
+ if raw.startswith(NEW_FILE_PREFIX):
55
+ current_file.new_name = raw[len(NEW_FILE_PREFIX):]
56
+ continue
57
+
58
+ if raw == "+++ /dev/null":
59
+ current_file.mode = FileMode.DELETED
60
+ continue
61
+
62
+ if raw == "--- /dev/null":
63
+ current_file.mode = FileMode.NEW
64
+ continue
65
+
66
+ if raw.startswith("@@ "):
67
+ match = HUNK_RE.match(raw)
68
+ if not match:
69
+ raise ValueError(f"Invalid hunk header: {raw}")
70
+
71
+ a, b, c, d, header = match.groups()
72
+ orig_start, orig_len = int(a), int(b or 0)
73
+ new_start, new_len = int(c), int(d or 0)
74
+
75
+ current_hunk = DiffHunk(
76
+ header=header or "",
77
+ orig_range=DiffRange(orig_start, orig_len, []),
78
+ new_range=DiffRange(new_start, new_len, []),
79
+ lines=[],
80
+ )
81
+ current_file.hunks.append(current_hunk)
82
+
83
+ added_count, removed_count = new_start, orig_start
84
+ continue
85
+
86
+ if current_hunk and is_source_line(raw):
87
+ line_type = get_line_type(raw)
88
+ content = raw[1:]
89
+
90
+ if line_type is DiffLineType.ADDED:
91
+ line = DiffLine(line_type, added_count, content, diff_pos)
92
+ current_hunk.new_range.lines.append(line)
93
+ current_hunk.lines.append(line)
94
+ added_count += 1
95
+
96
+ elif line_type is DiffLineType.REMOVED:
97
+ line = DiffLine(line_type, removed_count, content, diff_pos)
98
+ current_hunk.orig_range.lines.append(line)
99
+ current_hunk.lines.append(line)
100
+ removed_count += 1
101
+
102
+ else:
103
+ line_new = DiffLine(DiffLineType.UNCHANGED, added_count, content, diff_pos)
104
+ line_old = DiffLine(DiffLineType.UNCHANGED, removed_count, content, diff_pos)
105
+ current_hunk.new_range.lines.append(line_new)
106
+ current_hunk.orig_range.lines.append(line_old)
107
+ current_hunk.lines.append(line_new)
108
+ added_count += 1
109
+ removed_count += 1
110
+
111
+ return Diff(files=files, raw=diff_string)