Merge pull request !2810 from rhodecode-enterprise-ce poc/RCCE-298-MPV-for-AI-integration-for-code-review
Changes from branch: Poc/RCCE 298 MPV for AI integration for code review
This commit is contained in:
commit
9a41935690
37 changed files with 1253 additions and 42 deletions
|
|
@ -305,6 +305,8 @@ whoosh==2.7.4
|
|||
zope.cachedescriptors==5.1.0
|
||||
qrcode==7.4.2
|
||||
configupdater~=3.2
|
||||
openai~=1.100.2
|
||||
anthropic~=0.66.0
|
||||
|
||||
## uncomment to add the debug libraries
|
||||
#-r requirements_debug.txt
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ PYRAMID_SETTINGS = {}
|
|||
EXTENSIONS = {}
|
||||
|
||||
__version__ = ".".join((str(each) for each in VERSION[:3]))
|
||||
__dbversion__ = 118 # defines current db version for migrations
|
||||
__dbversion__ = 119 # defines current db version for migrations
|
||||
__license__ = "AGPLv3, and Commercial License"
|
||||
__author__ = "RhodeCode GmbH"
|
||||
__url__ = "https://code.rhodecode.com"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class TestGetUsers(object):
|
|||
ret_all = []
|
||||
_users = (
|
||||
User.query()
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER]))
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]))
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -146,7 +146,10 @@ def get_users(request, apiuser):
|
|||
|
||||
result = []
|
||||
users_list = (
|
||||
User.query().order_by(User.username).filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER])).all()
|
||||
User.query()
|
||||
.order_by(User.username)
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]))
|
||||
.all()
|
||||
)
|
||||
for user in users_list:
|
||||
result.append(user.get_api_data(include_secrets=True))
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class BaseAppView(object):
|
|||
if not user_obj:
|
||||
return
|
||||
|
||||
if user_obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
|
|
@ -270,7 +270,7 @@ class RepoAppView(BaseAppView):
|
|||
|
||||
def _prepare_and_set_clone_url(self, c):
|
||||
username = ""
|
||||
if self._rhodecode_user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if self._rhodecode_user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
username = self._rhodecode_user.username
|
||||
|
||||
_def_clone_uri = c.clone_uri_tmpl
|
||||
|
|
@ -558,7 +558,7 @@ class UserAppView(BaseAppView):
|
|||
|
||||
_ = self.request.translate
|
||||
if not request.db_user_supports_default:
|
||||
if self.db_user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if self.db_user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
h.flash(
|
||||
_("Editing user `{}` is disabled.".format(self.db_user.username)),
|
||||
category="warning",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ class NavEntry(object):
|
|||
class NavigationRegistry(object):
|
||||
_base_entries = [
|
||||
NavEntry("global", _("Global"), "admin_settings_global"),
|
||||
NavEntry("ai", _("AI features"), "admin_settings_ai"),
|
||||
NavEntry("vcs", _("VCS"), "admin_settings_vcs"),
|
||||
NavEntry("visual", _("Visual"), "admin_settings_visual"),
|
||||
NavEntry("mapping", _("Remap and Rescan"), "admin_settings_mapping"),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ def admin_routes(config):
|
|||
"""
|
||||
from rhodecode.apps.admin.views.audit_logs import AdminAuditLogsView
|
||||
from rhodecode.apps.admin.views.artifacts import AdminArtifactsView
|
||||
from rhodecode.apps.admin.views.ai import AdminAiView
|
||||
from rhodecode.apps.admin.views.automation import AdminAutomationView
|
||||
from rhodecode.apps.admin.views.scheduler import AdminSchedulerView
|
||||
from rhodecode.apps.admin.views.defaults import AdminDefaultSettingsView
|
||||
|
|
@ -147,6 +148,24 @@ def admin_routes(config):
|
|||
renderer="rhodecode:templates/admin/settings/settings.mako",
|
||||
)
|
||||
|
||||
config.add_route("admin_settings_ai", pattern="/settings/ai")
|
||||
config.add_view(
|
||||
AdminAiView,
|
||||
attr="admin_settings_ai",
|
||||
route_name="admin_settings_ai",
|
||||
request_method="GET",
|
||||
renderer="rhodecode:templates/admin/settings/settings.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="admin_settings_ai_update", pattern="/settings/ai/update")
|
||||
config.add_view(
|
||||
AdminAiView,
|
||||
attr="admin_settings_ai_update",
|
||||
route_name="admin_settings_ai_update",
|
||||
request_method="POST",
|
||||
renderer="rhodecode:templates/admin/settings/settings.mako",
|
||||
)
|
||||
|
||||
config.add_route(name="admin_settings_vcs_svn_generate_cfg", pattern="/settings/vcs/svn_generate_cfg")
|
||||
config.add_view(
|
||||
AdminSvnConfigView,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ class TestAdminUsersView(TestController):
|
|||
self.log_user()
|
||||
response = self.app.get(route_path("users_data"), extra_environ=xhr_header)
|
||||
|
||||
all_users = User.query().filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER])).count()
|
||||
all_users = (
|
||||
User.query().filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER])).count()
|
||||
)
|
||||
assert response.json["recordsTotal"] == all_users
|
||||
|
||||
def test_show_users_data_filtered(self, xhr_header):
|
||||
|
|
@ -50,7 +52,9 @@ class TestAdminUsersView(TestController):
|
|||
route_path("users_data", params={"search[value]": "empty_search"}), extra_environ=xhr_header
|
||||
)
|
||||
|
||||
all_users = User.query().filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER])).count()
|
||||
all_users = (
|
||||
User.query().filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER])).count()
|
||||
)
|
||||
assert response.json["recordsTotal"] == all_users
|
||||
assert response.json["recordsFiltered"] == 0
|
||||
|
||||
|
|
|
|||
101
rhodecode/apps/admin/views/ai.py
Normal file
101
rhodecode/apps/admin/views/ai.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import logging
|
||||
|
||||
import formencode
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from rhodecode.apps._base import BaseAppView
|
||||
from rhodecode.apps._base.navigation import navigation_list
|
||||
from rhodecode.apps.ai_agents.ai_settings import AIModelName, GPTVersion, ClaudeVersion, GeminiVersion
|
||||
from rhodecode.apps.ai_agents.models.base import AIServiceBase
|
||||
from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired
|
||||
from rhodecode.lib import helpers as h
|
||||
from rhodecode.model.db import User
|
||||
from rhodecode.model.forms import AiSettingsForm
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
from rhodecode.model.meta import Session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminAiView(BaseAppView):
|
||||
def load_default_context(self):
|
||||
c = self._get_local_tmpl_context()
|
||||
return c
|
||||
|
||||
@LoginRequired()
|
||||
@HasPermissionAllDecorator("hg.admin")
|
||||
def admin_settings_ai(self):
|
||||
c = self.load_default_context()
|
||||
c.active = "ai"
|
||||
c.navlist = navigation_list(self.request)
|
||||
|
||||
app_settings = c.rc_config
|
||||
c.selected_ai_model = app_settings.get("rhodecode_ai_model", AIModelName.GPT.name)
|
||||
c.selected_ai_model_version = app_settings.get("rhodecode_ai_model_version", GPTVersion.V5_nano.name)
|
||||
c.api_key = app_settings.get("rhodecode_ai_api_key")
|
||||
c.ai_features_enabled = app_settings.get("rhodecode_ai_features_enabled", False)
|
||||
|
||||
c.ai_instructions = app_settings.get("rhodecode_ai_code_review_instructions")
|
||||
if not c.ai_instructions:
|
||||
# to not run formatting each time
|
||||
c.ai_instructions = "\n".join(AIServiceBase.DEFAULT_BASIC_REVIEW_POINTS)
|
||||
|
||||
c.model_map = {
|
||||
AIModelName.GPT.name: [v.name for v in GPTVersion],
|
||||
AIModelName.Claude.name: [v.name for v in ClaudeVersion],
|
||||
AIModelName.Gemini.name: [v.name for v in GeminiVersion],
|
||||
}
|
||||
return self._get_template_context(c)
|
||||
|
||||
@CSRFRequired()
|
||||
@LoginRequired()
|
||||
@HasPermissionAllDecorator("hg.admin")
|
||||
def admin_settings_ai_update(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
c.active = "ai"
|
||||
|
||||
data = self._parse_form(_)
|
||||
self._save_settings(_, data)
|
||||
|
||||
raise HTTPFound(h.route_path("admin_settings_ai"))
|
||||
|
||||
def _save_settings(self, _, data):
|
||||
try:
|
||||
settings = [
|
||||
("ai_features_enabled", "rhodecode_ai_features_enabled", "bool"),
|
||||
("ai_model", "rhodecode_ai_model", "unicode"),
|
||||
("ai_model_version", "rhodecode_ai_model_version", "unicode"),
|
||||
("ai_api_key", "rhodecode_ai_api_key", "unicode"),
|
||||
("ai_code_review_instructions", "rhodecode_ai_code_review_instructions", "unicode"),
|
||||
]
|
||||
for setting, form_key, type_ in settings:
|
||||
sett = SettingsModel().create_or_update_setting(setting, data[form_key], type_)
|
||||
if setting == "ai_features_enabled" and isinstance(data[form_key], bool):
|
||||
self._activate_deactivate_ai_user(data, form_key)
|
||||
|
||||
Session().add(sett)
|
||||
|
||||
Session().commit()
|
||||
SettingsModel().invalidate_settings_cache()
|
||||
h.flash(_("AI settings saved"), category="success")
|
||||
except Exception as e:
|
||||
log.exception("Exception saving AI settings: %s", e)
|
||||
h.flash(_("Error occurred during saving AI settings"), category="error")
|
||||
|
||||
def _activate_deactivate_ai_user(self, data, form_key):
|
||||
log.debug("%s AI user" % "Activating" if data[form_key] else "Deactivating")
|
||||
ai_user = User.get_ai_user()
|
||||
ai_user.active = data[form_key]
|
||||
Session().add(ai_user)
|
||||
|
||||
def _parse_form(self, _):
|
||||
try:
|
||||
form = AiSettingsForm()()
|
||||
data = form.to_python(self.request.POST)
|
||||
except formencode.Invalid as errors:
|
||||
log.exception("Failed to add new pattern")
|
||||
error = errors
|
||||
h.flash(_(f"Unknown error: {error}"), category="error")
|
||||
raise HTTPFound(h.route_path("admin_settings_ai"))
|
||||
return data
|
||||
|
|
@ -104,7 +104,7 @@ class AdminUsersView(BaseAppView, DataGridAppView):
|
|||
def user_actions(user_id, username):
|
||||
return _render("user_actions", user_id, username)
|
||||
|
||||
excluded_users = User.username.notin_([User.DEFAULT_USER, User.GHOST_USER])
|
||||
excluded_users = User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER])
|
||||
users_data_total_count = User.query().filter(excluded_users).count()
|
||||
|
||||
users_data_total_inactive_count = User.query().filter(excluded_users).filter(User.active != true()).count()
|
||||
|
|
|
|||
0
rhodecode/apps/ai_agents/__init__.py
Normal file
0
rhodecode/apps/ai_agents/__init__.py
Normal file
37
rhodecode/apps/ai_agents/ai_service.py
Normal file
37
rhodecode/apps/ai_agents/ai_service.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from rhodecode.apps.ai_agents.ai_settings import AISettings, GPTVersion, AIModelName, ClaudeVersion, GeminiVersion
|
||||
from rhodecode.apps.ai_agents.models.base import wrap_ai_exceptions
|
||||
from rhodecode.apps.ai_agents.models.claude import ClaudeService
|
||||
from rhodecode.apps.ai_agents.models.gemini import GeminiService
|
||||
from rhodecode.apps.ai_agents.models.gpt import GPTService
|
||||
|
||||
|
||||
@wrap_ai_exceptions
|
||||
def get_ai_service(api_key: str, model_name: str = AIModelName.GPT.name, version: str = GPTVersion.V5_nano.name):
|
||||
assert api_key, "API key is required"
|
||||
try:
|
||||
if AIModelName[model_name] is AIModelName.GPT:
|
||||
return GPTService(
|
||||
AISettings(
|
||||
model_name=AIModelName.GPT,
|
||||
model_version=GPTVersion[version],
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
if AIModelName[model_name] is AIModelName.Claude:
|
||||
return ClaudeService(
|
||||
AISettings(
|
||||
model_name=AIModelName.Claude,
|
||||
model_version=ClaudeVersion[version],
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
if AIModelName[model_name] is AIModelName.Gemini:
|
||||
return GeminiService(
|
||||
AISettings(
|
||||
model_name=AIModelName.Gemini,
|
||||
model_version=GeminiVersion[version],
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
except KeyError as ke:
|
||||
raise ValueError(f"Unknown model or version: {ke}")
|
||||
43
rhodecode/apps/ai_agents/ai_settings.py
Normal file
43
rhodecode/apps/ai_agents/ai_settings.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class AIModelName(enum.StrEnum):
|
||||
GPT = enum.auto()
|
||||
Claude = enum.auto()
|
||||
Gemini = enum.auto()
|
||||
|
||||
|
||||
class GeminiVersion(enum.StrEnum):
|
||||
V2_5_pro = "2.5-pro"
|
||||
V2_5_flash = "2.5-flash"
|
||||
V2_0_flash = "2.0-flash"
|
||||
V2_5_flash_light = "2.5-flash-lite"
|
||||
V2_0_flash_light = "2.0-flash-lite"
|
||||
|
||||
|
||||
class ClaudeVersion(enum.StrEnum):
|
||||
Opus_41 = "opus-4-1"
|
||||
Opus_4 = "opus-4"
|
||||
Sonnet_4 = "sonnet-4"
|
||||
Sonnet_37 = "3-7-sonnet"
|
||||
Haiku_35 = "3-5-haiku"
|
||||
Haiku_3 = "3-haiku"
|
||||
|
||||
|
||||
class GPTVersion(enum.StrEnum):
|
||||
V5 = "5"
|
||||
V5_mini = "5-mini"
|
||||
V5_nano = "5-nano"
|
||||
V4_1 = "4.1"
|
||||
V4_1_mini = "4.1-mini"
|
||||
V4_1_nano = "4.1-nano"
|
||||
V4o = "4o"
|
||||
V4o_mini = "4o-mini"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AISettings:
|
||||
model_name: AIModelName
|
||||
model_version: enum.StrEnum
|
||||
api_key: str
|
||||
0
rhodecode/apps/ai_agents/models/__init__.py
Normal file
0
rhodecode/apps/ai_agents/models/__init__.py
Normal file
314
rhodecode/apps/ai_agents/models/base.py
Normal file
314
rhodecode/apps/ai_agents/models/base.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from typing import Optional, Iterable, Any
|
||||
|
||||
from rhodecode.apps.ai_agents.ai_settings import AISettings
|
||||
from rhodecode.lib.codeblocks import DiffSet
|
||||
from rhodecode.lib.vcs.nodes import FileNode
|
||||
|
||||
|
||||
FILE = "FILE:"
|
||||
|
||||
|
||||
class AIServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
model: str
|
||||
message: str
|
||||
error: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PingRequest(Request):
|
||||
function_name: str
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Review:
|
||||
role: str
|
||||
msg: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeReviewRequest(Request):
|
||||
content: list[Review]
|
||||
function_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomFunctions:
|
||||
Ping: str = "ping_pong"
|
||||
Code_review: str = "return_code_review"
|
||||
|
||||
|
||||
def wrap_ai_exceptions(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
exc = AIServiceError(str(e))
|
||||
# to not lose the original traceback
|
||||
exc.__cause__ = e
|
||||
raise exc.with_traceback(e.__traceback__)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AIServiceBase:
|
||||
DEFAULT_BASIC_REVIEW_POINTS = [
|
||||
"Correctness and edge cases (logic errors, boundary conditions, invalid inputs).",
|
||||
"Error handling and resilience (fail-fast where appropriate, clear propagation, retries/backoff, cleanup).",
|
||||
"Security (input validation/sanitization, injection risks, unsafe eval/exec, authn/z, secret handling, serialization).",
|
||||
"Readability and maintainability (clear names, comments/docs where helpful, remove dead code, consistent formatting).",
|
||||
"Interface & API design (encapsulation, stable contracts, backward compatibility, minimal surface area).",
|
||||
"Performance & complexity (hot paths, unnecessary allocations/work, algorithmic complexity, scalability).",
|
||||
"Concurrency & asynchrony (race conditions, synchronization, thread/process safety, async/await or equivalents).",
|
||||
"Resource management (files, network, DBs; timeouts; quotas; connection pooling; deterministic cleanup).",
|
||||
"Observability (useful logging, metrics, tracing; avoid sensitive data in logs; actionable error messages).",
|
||||
"Testability & testing (deterministic seams, unit/integration tests, fixtures/mocks, meaningful coverage).",
|
||||
"Dependency & supply-chain hygiene (version constraints, provenance, minimal deps, portability).",
|
||||
"Portability & interoperability (standards compliance, platform differences, encoding/locale issues).",
|
||||
]
|
||||
|
||||
def __init__(self, model_settings: AISettings):
|
||||
self.model_settings = model_settings
|
||||
self._validate_mandatory_settings()
|
||||
|
||||
@wrap_ai_exceptions
|
||||
def _validate_mandatory_settings(self):
|
||||
api_key = self.model_settings.api_key
|
||||
assert api_key is not None and api_key, "API key is required"
|
||||
|
||||
name = self.model_settings.model_name
|
||||
assert name is not None and name, "Model name is required"
|
||||
|
||||
version = self.model_settings.model_version
|
||||
assert version is not None and version, "Model version is required"
|
||||
|
||||
@wrap_ai_exceptions
|
||||
def ping(self) -> Response:
|
||||
resp = self._get_response(self._get_ping_request())
|
||||
return self._transform(resp)
|
||||
|
||||
@wrap_ai_exceptions
|
||||
def code_review(self, pr_diffset: DiffSet, *args, **kwargs) -> Response:
|
||||
resp = self._get_response(self._get_review_requests(pr_diffset, *args, **kwargs))
|
||||
return self._transform(resp)
|
||||
|
||||
def get_code_review_instructions(self, basic_points, review_content, custom_instructions=None):
|
||||
user_msg = (
|
||||
"BASIC REVIEW INSTRUCTIONS:\n"
|
||||
+ "\n".join(f"- {point}" for point in basic_points)
|
||||
+ ("\n\nADDITIONAL CUSTOM INSTRUCTIONS:\n" + custom_instructions if custom_instructions else "")
|
||||
+ "\n\nIMPORTANT OUTPUT RULES:\n"
|
||||
f"- Use the function call to return `{CustomFunctions.Code_review}`: a list of tuples "
|
||||
" [line_number:int (1-based, exactly same index as it was in the request per file), line_text:str, suggestion:str].\n"
|
||||
"- Only include tuples for lines with a concrete, actionable suggestion.\n"
|
||||
"- Keep suggestions concise and specific.\n"
|
||||
"- If many lines share the same issue, include a representative subset and list other line numbers.\n"
|
||||
"- Do not include overall prose; only return via the function.\n"
|
||||
+ "\nFILES (numbered per file):\n"
|
||||
+ self.numbered_code_block(review_content)
|
||||
)
|
||||
return user_msg
|
||||
|
||||
def numbered_code_block(self, review_content: list[dict]) -> str:
|
||||
"""
|
||||
Render code with explicit 1-based line numbers so the model can reference them precisely.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for content in review_content:
|
||||
full_code = content["content"].splitlines()
|
||||
only_include_lines: list[int] = content["changed_lines"]
|
||||
width = max(2, len(str(len(full_code)))) if full_code else 2
|
||||
part_template = f"{FILE} %s\nTYPE: %s"
|
||||
parts.append(part_template % (content["file_name"], content["file_type"]))
|
||||
if full_code:
|
||||
for i, line in enumerate(full_code, start=1):
|
||||
if i in only_include_lines:
|
||||
# include only changes without losing index
|
||||
parts.append(f"{str(i).rjust(width)} | {line}")
|
||||
else:
|
||||
parts.append("(empty file)")
|
||||
parts.append("") # blank line between files
|
||||
return "\n".join(parts).rstrip()
|
||||
|
||||
def _get_response_input(self, request: PingRequest | CodeReviewRequest) -> list[dict[str, str]]:
|
||||
"""
|
||||
It extracts data from internal data classes and transforms it into an input value suitable for the model’s SDK.
|
||||
"""
|
||||
_input = None
|
||||
if isinstance(request, CodeReviewRequest):
|
||||
return [
|
||||
{
|
||||
"role": review.role,
|
||||
"content": review.msg,
|
||||
}
|
||||
for review in request.content
|
||||
]
|
||||
|
||||
if isinstance(request, PingRequest):
|
||||
return [
|
||||
{
|
||||
"role": request.role,
|
||||
"content": request.content,
|
||||
}
|
||||
]
|
||||
|
||||
raise ValueError("Unknown request type: %s" % type(request))
|
||||
|
||||
def _extract_changes(self, pr_diffset: DiffSet) -> list[Any]:
|
||||
review_content = []
|
||||
for f in pr_diffset.files:
|
||||
target_file_node: FileNode = f.get("target_filenode", None)
|
||||
if not target_file_node:
|
||||
self.log.warning("No target file node, skipping it")
|
||||
continue
|
||||
|
||||
file_name = f.get("patch", {}).get("filename", "unknown")
|
||||
if not target_file_node.str_content:
|
||||
self.log.debug("No content, skipping file: %s", file_name)
|
||||
continue
|
||||
|
||||
modified_lines_nums = []
|
||||
for hunk in f.get("hunks", []):
|
||||
for line in hunk.get("lines", []):
|
||||
action = line.get("modified", {}).get("action", "").strip()
|
||||
if action and action != "-":
|
||||
modified_lines_nums.append(line.get("modified", {}).get("lineno"))
|
||||
|
||||
file_content = {
|
||||
"content": target_file_node.str_content,
|
||||
"changed_lines": modified_lines_nums,
|
||||
"file_name": file_name,
|
||||
"file_type": f.target_file_type,
|
||||
}
|
||||
|
||||
review_content.append(file_content)
|
||||
return review_content
|
||||
|
||||
def _get_system_message(self) -> str:
|
||||
return (
|
||||
"You are a meticulous senior code reviewer. "
|
||||
"You must ONLY return your findings by calling the provided function "
|
||||
f"`{CustomFunctions.Code_review}` with the required JSON shape. "
|
||||
"Each tuple = [line_number:int (1-based, GLOBAL across all files), line_text:str, suggestion:str]. "
|
||||
"Only include lines that genuinely warrant a suggestion. "
|
||||
"The input may mix languages; use each file's TYPE header and the line's syntax to tailor advice. "
|
||||
"If a suggestion spans lines or files, attach it to the most relevant line and mention the related line numbers. "
|
||||
"Treat 'Plain Text' as docs/logs/config and suggest clarity/safety where relevant."
|
||||
)
|
||||
|
||||
def get_model_name(self, model_name=None):
|
||||
if model_name:
|
||||
return model_name
|
||||
name = self.model_settings.model_name.value
|
||||
version = self.model_settings.model_version.value
|
||||
return "%s-%s" % (name.strip().lower(), version.strip().lower())
|
||||
|
||||
def _get_function(self, name):
|
||||
"""
|
||||
this is a GPT/Claude-specific function - AKA instruction for GPT
|
||||
doc: https://platform.openai.com/docs/guides/function-calling
|
||||
|
||||
Claude uses the same tools method
|
||||
"""
|
||||
match name:
|
||||
case CustomFunctions.Ping:
|
||||
return {
|
||||
"type": "function",
|
||||
"name": CustomFunctions.Ping,
|
||||
"description": "Always return pong",
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"response": {"type": "string", "enum": ["pong"]}},
|
||||
"required": ["response"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
case CustomFunctions.Code_review:
|
||||
return {
|
||||
"type": "function",
|
||||
"name": CustomFunctions.Code_review,
|
||||
"description": (
|
||||
"Return structured code review per file. Each file uses per-file 1-based "
|
||||
"line numbers. Only include lines that warrant a concrete suggestion."
|
||||
),
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_name": {"type": "string"},
|
||||
"review": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"line_number": {"type": "integer", "minimum": 1},
|
||||
"line_code": {"type": "string"},
|
||||
"suggestion": {"type": "string"},
|
||||
},
|
||||
"required": ["line_number", "line_code", "suggestion"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["file_name", "review"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["response"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
case _:
|
||||
raise ValueError("Unknown function name: %s" % name)
|
||||
|
||||
def _get_ping_request(self) -> Request:
|
||||
return PingRequest(content="ping", function_name=CustomFunctions.Ping, role="user")
|
||||
|
||||
def _get_review_requests(
|
||||
self,
|
||||
pr_diffset: DiffSet,
|
||||
basic_instructions: Optional[Iterable[str]] = None,
|
||||
custom_instructions: Optional[str] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> CodeReviewRequest:
|
||||
review_content = self._extract_changes(pr_diffset)
|
||||
|
||||
system_msg = self._get_system_message()
|
||||
|
||||
basic_points = list(basic_instructions) if basic_instructions else self.DEFAULT_BASIC_REVIEW_POINTS
|
||||
|
||||
user_msg = self.get_code_review_instructions(basic_points, review_content, custom_instructions)
|
||||
|
||||
return CodeReviewRequest(
|
||||
content=[Review(role="system", msg=system_msg), Review(role="user", msg=user_msg)],
|
||||
function_name=CustomFunctions.Code_review,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _get_response(self, request: PingRequest | CodeReviewRequest) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
def _transform(self, resp, model=None) -> Response:
|
||||
return Response(model=self.get_model_name(model_name=model), message=resp)
|
||||
113
rhodecode/apps/ai_agents/models/claude.py
Normal file
113
rhodecode/apps/ai_agents/models/claude.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import logging
|
||||
import math
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from anthropic import Anthropic
|
||||
from anthropic.types import ToolParam
|
||||
|
||||
from rhodecode.apps.ai_agents.ai_settings import AISettings, AIModelName, ClaudeVersion
|
||||
from rhodecode.apps.ai_agents.models.base import (
|
||||
AIServiceBase,
|
||||
Response,
|
||||
AIServiceError,
|
||||
PingRequest,
|
||||
CodeReviewRequest,
|
||||
Review,
|
||||
CustomFunctions,
|
||||
FILE,
|
||||
)
|
||||
from rhodecode.lib.codeblocks import DiffSet
|
||||
|
||||
|
||||
class ClaudeService(AIServiceBase):
|
||||
def __init__(self, model_settings: AISettings):
|
||||
super().__init__(model_settings)
|
||||
self._client = Anthropic(api_key=self.model_settings.api_key)
|
||||
self.log = logging.getLogger(ClaudeService.__name__)
|
||||
|
||||
def _get_response(self, request: PingRequest | CodeReviewRequest):
|
||||
# maximum number of output tokens Claude can generate before stopping (does not include input tokens)
|
||||
max_tokens = self._calculate_max_tokens(request)
|
||||
self.log.debug("estimated max output tokens: %s", max_tokens)
|
||||
|
||||
_input = self._get_response_input(request)
|
||||
|
||||
model_full_name = self._get_current_model_version()
|
||||
|
||||
function = self._adapt_function(self._get_function(request.function_name))
|
||||
|
||||
return self._client.messages.create(
|
||||
model=model_full_name,
|
||||
messages=_input,
|
||||
max_tokens=max_tokens,
|
||||
tools=[
|
||||
function,
|
||||
],
|
||||
)
|
||||
|
||||
def _adapt_function(self, function_dict: dict) -> ToolParam:
|
||||
return ToolParam(
|
||||
name=function_dict["name"],
|
||||
description=function_dict["description"],
|
||||
input_schema=function_dict["parameters"],
|
||||
)
|
||||
|
||||
def _transform(self, resp, model=None) -> Response:
|
||||
for r in resp.content:
|
||||
self.log.debug("response: %s", r)
|
||||
if r.type == "tool_use" and r.name in [CustomFunctions.Code_review, CustomFunctions.Ping]:
|
||||
return Response(model=resp.model, message=r.input)
|
||||
raise ValueError("Response has incorrect return type, can't parses it.")
|
||||
|
||||
def _get_current_model_version(self):
|
||||
models = self._client.models.list()
|
||||
semi_key = "%s-%s" % (
|
||||
self.model_settings.model_name.lower().strip(),
|
||||
self.model_settings.model_version.lower().strip(),
|
||||
)
|
||||
for model in models:
|
||||
if semi_key in model.id:
|
||||
return model.id
|
||||
raise AIServiceError("Unknown model version: %s" % semi_key)
|
||||
|
||||
def _calculate_max_tokens(self, request: PingRequest | CodeReviewRequest):
|
||||
default = 256
|
||||
|
||||
if isinstance(request, CodeReviewRequest):
|
||||
user_msg: Review = next(filter(lambda r: FILE in r.msg, request.content), None)
|
||||
if user_msg:
|
||||
user_msg_code_lines = user_msg.msg.count(FILE)
|
||||
return self._estimate_max_tokens(user_msg_code_lines)
|
||||
|
||||
return default
|
||||
|
||||
def _estimate_max_tokens(
|
||||
self,
|
||||
lines: int,
|
||||
estimated_sentences_per_line: float = 3.0,
|
||||
average_words_per_english_sentence: float = 20.0,
|
||||
rough_heuristic_tokens_per_word: float = 4,
|
||||
safety_buffer_percent: float = 0.15,
|
||||
) -> int:
|
||||
"""
|
||||
Estimate a safe `max_tokens` (output only) for code review.
|
||||
"""
|
||||
raw = (
|
||||
lines * estimated_sentences_per_line * average_words_per_english_sentence * rough_heuristic_tokens_per_word
|
||||
)
|
||||
return math.ceil(raw * (1.0 + safety_buffer_percent))
|
||||
|
||||
def _get_review_requests(
|
||||
self,
|
||||
pr_diffset: DiffSet,
|
||||
basic_instructions: Optional[Iterable[str]] = None,
|
||||
custom_instructions: Optional[str] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> CodeReviewRequest:
|
||||
# Claude has only 2 roles: user, assistant
|
||||
requests = super()._get_review_requests(pr_diffset, basic_instructions, custom_instructions, *args, **kwargs)
|
||||
for r in requests.content:
|
||||
r.role = "user"
|
||||
|
||||
return requests
|
||||
63
rhodecode/apps/ai_agents/models/gemini.py
Normal file
63
rhodecode/apps/ai_agents/models/gemini.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from rhodecode.apps.ai_agents.ai_settings import AISettings
|
||||
|
||||
from rhodecode.apps.ai_agents.models.base import (
|
||||
PingRequest,
|
||||
CodeReviewRequest,
|
||||
CustomFunctions,
|
||||
Response,
|
||||
AIServiceBase,
|
||||
)
|
||||
|
||||
|
||||
class GeminiService(AIServiceBase):
|
||||
"""
|
||||
uses google compatibility option with OpenAI library: https://ai.google.dev/gemini-api/docs/openai
|
||||
"""
|
||||
|
||||
def __init__(self, model_settings: AISettings):
|
||||
super().__init__(model_settings)
|
||||
self._client = OpenAI(
|
||||
api_key=self.model_settings.api_key,
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
self.log = logging.getLogger(GeminiService.__name__)
|
||||
|
||||
def _get_response(self, request: PingRequest | CodeReviewRequest):
|
||||
_input = self._get_response_input(request)
|
||||
|
||||
function = self._adapt_function(self._get_function(request.function_name))
|
||||
|
||||
return self._client.chat.completions.create(
|
||||
model=self.get_model_name(),
|
||||
messages=_input,
|
||||
tools=[function],
|
||||
tool_choice={"type": "function", "function": {"name": request.function_name}},
|
||||
parallel_tool_calls=False,
|
||||
)
|
||||
|
||||
def _transform(self, resp):
|
||||
for item in resp.choices:
|
||||
self.log.debug("response: %s", item)
|
||||
if item.finish_reason == "tool_calls":
|
||||
for tool in item.message.tool_calls:
|
||||
if tool.function.name in [CustomFunctions.Code_review, CustomFunctions.Ping]:
|
||||
return Response(model=resp.model, message=json.loads(tool.function.arguments))
|
||||
|
||||
raise ValueError("No function call found in response")
|
||||
|
||||
def _adapt_function(self, function_dict: dict):
|
||||
original_copy = deepcopy(function_dict)
|
||||
|
||||
del original_copy["type"]
|
||||
del original_copy["strict"]
|
||||
|
||||
return {
|
||||
"type": "function",
|
||||
"function": original_copy,
|
||||
}
|
||||
37
rhodecode/apps/ai_agents/models/gpt.py
Normal file
37
rhodecode/apps/ai_agents/models/gpt.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from rhodecode.apps.ai_agents.ai_settings import AISettings
|
||||
from rhodecode.apps.ai_agents.models.base import AIServiceBase, PingRequest, CodeReviewRequest, CustomFunctions
|
||||
|
||||
|
||||
class GPTService(AIServiceBase):
|
||||
def __init__(self, model_settings: AISettings):
|
||||
super().__init__(model_settings)
|
||||
self._client = OpenAI(api_key=self.model_settings.api_key)
|
||||
self.log = logging.getLogger(GPTService.__name__)
|
||||
|
||||
def _get_response(self, request: PingRequest | CodeReviewRequest):
|
||||
_input = self._get_response_input(request)
|
||||
|
||||
return self._client.responses.create(
|
||||
model=self.get_model_name(),
|
||||
input=_input,
|
||||
tools=[
|
||||
self._get_function(request.function_name),
|
||||
],
|
||||
tool_choice={"type": "function", "name": request.function_name},
|
||||
parallel_tool_calls=False,
|
||||
)
|
||||
|
||||
def _transform(self, resp):
|
||||
for item in resp.output:
|
||||
if item.type == "function_call" and item.name in [CustomFunctions.Code_review, CustomFunctions.Ping]:
|
||||
return super()._transform(
|
||||
resp=json.loads(item.arguments),
|
||||
model=resp.model,
|
||||
)
|
||||
|
||||
raise ValueError("No function call found in response")
|
||||
|
|
@ -239,7 +239,7 @@ class HomeView(BaseAppView, DataGridAppView):
|
|||
User.query()
|
||||
.order_by(func.length(User.username))
|
||||
.order_by(User.username)
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER]))
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]))
|
||||
)
|
||||
|
||||
if name_contains:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from rhodecode.apps._base import RepoAppView, DataGridAppView
|
|||
|
||||
from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
|
||||
from rhodecode.lib.base import vcs_operation_context
|
||||
from rhodecode.lib.celerylib import run_task, tasks
|
||||
from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
|
||||
from rhodecode.lib.exceptions import CommentVersionMismatch
|
||||
from rhodecode.lib import ext_json
|
||||
|
|
@ -54,6 +55,7 @@ from rhodecode.model.db import (
|
|||
ChangesetStatus,
|
||||
Repository,
|
||||
PullRequestReviewers,
|
||||
User,
|
||||
)
|
||||
from rhodecode.model.forms import PullRequestForm
|
||||
from rhodecode.model.meta import Session
|
||||
|
|
@ -1310,13 +1312,48 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
|
|||
do_update_branch_close = "close_branch_before_merging" in self.request.POST
|
||||
|
||||
if "review_members" in controls:
|
||||
ai_user = User.get_ai_user()
|
||||
ai_user_id = ai_user.user_id
|
||||
|
||||
members_ = controls["review_members"]
|
||||
for i, member_data in enumerate(members_):
|
||||
user_id = safe_int(member_data.get("user_id", -1))
|
||||
|
||||
if ai_user_id == user_id:
|
||||
admin_permissions = (
|
||||
"repository.admin",
|
||||
"hg.admin",
|
||||
)
|
||||
is_admin = h.HasRepoPermissionAny(*admin_permissions)(
|
||||
user=self._rhodecode_user, repo_name=pull_request.target_repo.repo_name
|
||||
)
|
||||
|
||||
if is_admin:
|
||||
log.debug("Initiating code review for pull request id: %s", pull_request.pull_request_id)
|
||||
run_task(tasks.start_ai_code_review, pull_request.pull_request_id)
|
||||
msg = _("AI code review has started. The review will be ready in about 1–3 minutes.")
|
||||
h.flash(msg, category="success")
|
||||
else:
|
||||
log.debug("AI code review canceled, only admin can initiate AI code review.")
|
||||
msg = _(
|
||||
"AI code review is not available. This feature is in BETA and can only be initiated by an admin."
|
||||
)
|
||||
h.flash(
|
||||
msg,
|
||||
category="warning",
|
||||
)
|
||||
del members_[i]
|
||||
|
||||
break
|
||||
|
||||
self._update_reviewers(
|
||||
c,
|
||||
pull_request,
|
||||
controls["review_members"],
|
||||
members_,
|
||||
pull_request.reviewer_data,
|
||||
PullRequestReviewers.ROLE_REVIEWER,
|
||||
)
|
||||
|
||||
elif "observer_members" in controls:
|
||||
self._update_reviewers(
|
||||
c,
|
||||
|
|
@ -1438,6 +1475,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
|
|||
PullRequestModel().get_reviewer_functions()
|
||||
)
|
||||
|
||||
ids_to_add = None
|
||||
ids_to_remove = None
|
||||
|
||||
if role == PullRequestReviewers.ROLE_REVIEWER:
|
||||
try:
|
||||
reviewers = validate_default_reviewers(review_members, reviewer_rules)
|
||||
|
|
@ -1447,7 +1487,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
|
|||
return
|
||||
|
||||
old_calculated_status = pull_request.calculated_review_status()
|
||||
PullRequestModel().update_reviewers(pull_request, reviewers, self._rhodecode_db_user)
|
||||
ids_to_add, ids_to_remove = PullRequestModel().update_reviewers(
|
||||
pull_request, reviewers, self._rhodecode_db_user
|
||||
)
|
||||
|
||||
Session().commit()
|
||||
|
||||
|
|
@ -1470,13 +1512,17 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
|
|||
h.flash(e, category="error")
|
||||
return
|
||||
|
||||
PullRequestModel().update_observers(pull_request, observers, self._rhodecode_db_user)
|
||||
ids_to_add, ids_to_remove = PullRequestModel().update_observers(
|
||||
pull_request, observers, self._rhodecode_db_user
|
||||
)
|
||||
|
||||
Session().commit()
|
||||
msg = _("Pull request observers updated.")
|
||||
h.flash(msg, category="success")
|
||||
channelstream.pr_update_channelstream_push(self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
|
||||
|
||||
return ids_to_add, ids_to_remove
|
||||
|
||||
@LoginRequired()
|
||||
@NotAnonymous()
|
||||
@HasRepoPermissionAnyDecorator("repository.read", "repository.write", "repository.admin")
|
||||
|
|
|
|||
|
|
@ -53,7 +53,16 @@ class TestUsersController(TestController):
|
|||
assert_response = response.assert_response()
|
||||
assert_response.element_contains(edit_link_css, "Edit")
|
||||
|
||||
def test_user_profile_not_available(self, user_util):
|
||||
@pytest.mark.parametrize(
|
||||
"user",
|
||||
[
|
||||
User.DEFAULT_USER,
|
||||
User.GHOST_USER,
|
||||
User.AI_USER,
|
||||
"unknown",
|
||||
],
|
||||
)
|
||||
def test_user_profile_not_available(self, user_util, user):
|
||||
user = user_util.create_user()
|
||||
username = user.username
|
||||
|
||||
|
|
@ -64,11 +73,5 @@ class TestUsersController(TestController):
|
|||
# after log-in show
|
||||
self.app.get(route_path("user_profile", username=username), status=200)
|
||||
|
||||
# default user, not allowed to show it
|
||||
# default user or system user, not allowed to show it
|
||||
self.app.get(route_path("user_profile", username=User.DEFAULT_USER), status=404)
|
||||
|
||||
# ghost user, not allowed to show it
|
||||
self.app.get(route_path("user_profile", username=User.GHOST_USER), status=404)
|
||||
|
||||
# actual 404
|
||||
self.app.get(route_path("user_profile", username="unknown"), status=404)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class UserProfileView(BaseAppView):
|
|||
username = self.request.matchdict.get("username")
|
||||
|
||||
c.user = UserModel().get_by_username(username)
|
||||
if not c.user or c.user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if not c.user or c.user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
raise HTTPNotFound()
|
||||
|
||||
return self._get_template_context(c)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,14 @@ log = logging.getLogger(__name__)
|
|||
|
||||
PR_IDS_REVIEWER_ROLE = "pull_request_ids_reviewer_role"
|
||||
PR_IDS_OBSERVER_ROLE = "pull_request_ids_observer_role"
|
||||
PR_ID = "pull_request_id"
|
||||
AI_MODEL = "ai_model"
|
||||
AI_MODEL_VERSION = "ai_model_version"
|
||||
|
||||
# action as key, and expected action_data as value
|
||||
ACTIONS_V1 = {
|
||||
"ai.code-review.start": {PR_ID: 0, AI_MODEL: "", AI_MODEL_VERSION: ""},
|
||||
"ai.code-review.finish": {PR_ID: 0, AI_MODEL: "", "error": False, "error_message": ""},
|
||||
"user.login.success": {"user_agent": ""},
|
||||
"user.login.failure": {"user_agent": ""},
|
||||
"user.logout": {"user_agent": ""},
|
||||
|
|
|
|||
|
|
@ -1299,6 +1299,10 @@ class AuthUser(object):
|
|||
def is_ghost(self):
|
||||
return self.username == User.GHOST_USER
|
||||
|
||||
@property
|
||||
def is_ai(self):
|
||||
return self.username == User.AI_USER
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.admin
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ by celery daemon
|
|||
|
||||
import os
|
||||
import time
|
||||
from logging import Logger
|
||||
from typing import Any
|
||||
|
||||
from celery import current_app
|
||||
from pyramid_mailer.mailer import Mailer
|
||||
|
|
@ -30,14 +32,31 @@ from pyramid_mailer.message import Message
|
|||
from email.utils import formatdate
|
||||
|
||||
import rhodecode
|
||||
from rhodecode.lib import audit_logger
|
||||
from rhodecode.apps.ai_agents.ai_service import get_ai_service
|
||||
from rhodecode.apps.ai_agents.models.base import Response, AIServiceError
|
||||
from rhodecode.lib import audit_logger, diffs, codeblocks
|
||||
from rhodecode.lib.celerylib import get_logger, async_task, RequestContextTask, run_task
|
||||
from rhodecode.lib import hooks_base
|
||||
from rhodecode.lib.diffs import MAX_CONTEXT
|
||||
from rhodecode.lib.str_utils import safe_str
|
||||
from rhodecode.lib.utils import adopt_for_celery
|
||||
from rhodecode.lib.utils2 import safe_int, str2bool, aslist
|
||||
from rhodecode.lib.statsd_client import StatsdClient
|
||||
from rhodecode.model.db import true, null, Session, IntegrityError, Repository, RepoGroup, User, ScheduleEntry
|
||||
from rhodecode.model.comment import CommentsModel
|
||||
from rhodecode.model.db import (
|
||||
true,
|
||||
null,
|
||||
Session,
|
||||
IntegrityError,
|
||||
Repository,
|
||||
RepoGroup,
|
||||
User,
|
||||
ScheduleEntry,
|
||||
PullRequest,
|
||||
)
|
||||
from rhodecode.model.permission import PermissionModel
|
||||
from rhodecode.model.pull_request import PullRequestModel
|
||||
from rhodecode.model.settings import SettingsModel
|
||||
|
||||
|
||||
@async_task(ignore_result=True, base=RequestContextTask)
|
||||
|
|
@ -459,6 +478,169 @@ def unschedule_sync_ldap_ad_users_producer():
|
|||
Session().delete(existing_task)
|
||||
|
||||
|
||||
@async_task
|
||||
def start_ai_code_review(pull_request_id):
|
||||
log = get_logger(start_ai_code_review)
|
||||
log.info("Starting AI code review for pull request id: %s", pull_request_id)
|
||||
rc_settings = SettingsModel()
|
||||
ai_enabled = rc_settings.get_setting_by_name("ai_features_enabled")
|
||||
if not ai_enabled:
|
||||
log.info("AI code review is disabled.")
|
||||
return
|
||||
|
||||
pull_request: PullRequest = PullRequest.get(pull_request_id)
|
||||
|
||||
if not pull_request:
|
||||
log.info("Pull request id: %s not found, review not possible.", pull_request_id)
|
||||
return
|
||||
|
||||
if pull_request.is_state_changing():
|
||||
log.info("Pull request id: %s is in state changing, review not possible.", pull_request_id)
|
||||
return
|
||||
|
||||
settings_valid = _validate_settings(rc_settings, log)
|
||||
if not settings_valid:
|
||||
return
|
||||
|
||||
ai_user = User.get_ai_user()
|
||||
|
||||
diffset = _get_diffset(pull_request)
|
||||
ai_model = rc_settings.get_setting_by_name("ai_model").app_settings_value
|
||||
ai_model_version = rc_settings.get_setting_by_name("ai_model_version").app_settings_value
|
||||
ai_api_key = rc_settings.get_setting_by_name("ai_api_key").app_settings_value
|
||||
|
||||
if not ai_model or not ai_model or not ai_api_key:
|
||||
log.info("AI model or model version or API key is not set, review not possible.")
|
||||
return
|
||||
|
||||
service = get_ai_service(
|
||||
api_key=ai_api_key,
|
||||
model_name=ai_model,
|
||||
version=ai_model_version,
|
||||
)
|
||||
instructions = rc_settings.get_setting_by_name("ai_code_review_instructions")
|
||||
if instructions:
|
||||
instructions = instructions.app_settings_value.split("\r\n")
|
||||
|
||||
audit_logger.store(
|
||||
"ai.code-review.start",
|
||||
user=ai_user,
|
||||
action_data={
|
||||
audit_logger.PR_ID: pull_request_id,
|
||||
audit_logger.AI_MODEL: ai_model,
|
||||
audit_logger.AI_MODEL_VERSION: ai_model_version,
|
||||
},
|
||||
repo=pull_request.target_repo,
|
||||
)
|
||||
|
||||
try:
|
||||
response = service.code_review(diffset, instructions=instructions)
|
||||
_add_comments(response, pull_request, log, ai_user)
|
||||
audit_logger.store(
|
||||
"ai.code-review.finish",
|
||||
user=ai_user,
|
||||
action_data={
|
||||
audit_logger.PR_ID: pull_request_id,
|
||||
audit_logger.AI_MODEL: response.model,
|
||||
"error": response.error,
|
||||
"error_message": "",
|
||||
},
|
||||
repo=pull_request.target_repo,
|
||||
)
|
||||
|
||||
except AIServiceError as e:
|
||||
log.error("AI service error: %s", e)
|
||||
audit_logger.store(
|
||||
"ai.code-review.finish",
|
||||
user=ai_user,
|
||||
action_data={
|
||||
audit_logger.PR_ID: pull_request_id,
|
||||
audit_logger.AI_MODEL: service.get_model_name(),
|
||||
"error": True,
|
||||
"error_message": str(e),
|
||||
},
|
||||
repo=pull_request.target_repo,
|
||||
)
|
||||
|
||||
|
||||
def _add_comments(response: Response, pull_request: PullRequest, log: Logger | Any, ai_user: User):
|
||||
log.debug(
|
||||
"Response from AI service: %s",
|
||||
response.message,
|
||||
)
|
||||
for response_line in response.message.get("response", []):
|
||||
file_name = response_line.get("file_name")
|
||||
review = response_line.get("review", [])
|
||||
log.debug("Review for file: %s", file_name)
|
||||
for review_line in review:
|
||||
line_number = review_line.get("line_number")
|
||||
line_code = review_line.get("line_code")
|
||||
suggestion = review_line.get("suggestion")
|
||||
log.debug("%s | %s | %s", line_number, line_code, suggestion)
|
||||
insert_comment(line_number, suggestion, file_name, pull_request, ai_user)
|
||||
|
||||
|
||||
def insert_comment(line_number: int, text: str, f_path: str, pull_request: PullRequest, ai_user: User):
|
||||
target_repo: Repository = pull_request.target_repo
|
||||
repo_id = target_repo.repo_id
|
||||
line_number_formatted = "n%s" % line_number
|
||||
|
||||
comment_type = "note"
|
||||
|
||||
CommentsModel().create(
|
||||
text=text,
|
||||
repo=repo_id,
|
||||
user=ai_user.user_id,
|
||||
pull_request=pull_request,
|
||||
f_path=f_path,
|
||||
line_no=line_number_formatted,
|
||||
comment_type=comment_type,
|
||||
send_email=False,
|
||||
)
|
||||
|
||||
|
||||
def _validate_settings(rc_settings, log):
|
||||
if not rc_settings.get_setting_by_name("ai_api_key"):
|
||||
log.info("AI API key is not set, review not possible.")
|
||||
return False
|
||||
if not rc_settings.get_setting_by_name("ai_model"):
|
||||
log.info("AI model is not set, review not possible.")
|
||||
return False
|
||||
if not rc_settings.get_setting_by_name("ai_model_version"):
|
||||
log.info("AI model version is not set, review not possible.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_diffset(
|
||||
pull_request: PullRequest,
|
||||
):
|
||||
source_repo: Repository = pull_request.source_repo
|
||||
target_repo: Repository = pull_request.target_repo
|
||||
|
||||
source_ref_id = pull_request.source_ref_parts.commit_id
|
||||
target_ref_id = pull_request.target_ref_parts.commit_id
|
||||
|
||||
pre_load = ["author", "date", "message", "branch", "parents"]
|
||||
target_commit_final = target_repo.get_commit(commit_id=safe_str(target_ref_id), pre_load=pre_load)
|
||||
source_commit_final = source_repo.get_commit(commit_id=safe_str(source_ref_id), pre_load=pre_load)
|
||||
|
||||
vcs_diff = PullRequestModel().get_diff(source_repo, source_ref_id, target_ref_id, False, MAX_CONTEXT)
|
||||
|
||||
diff_processor = diffs.DiffProcessor(vcs_diff, diff_format="newdiff")
|
||||
|
||||
_parsed = diff_processor.prepare()
|
||||
|
||||
diffset = codeblocks.DiffSet(
|
||||
repo_name=target_repo.repo_name,
|
||||
source_repo_name=source_repo.repo_name,
|
||||
source_node_getter=codeblocks.diffset_node_getter(target_commit_final),
|
||||
target_node_getter=codeblocks.diffset_node_getter(source_commit_final),
|
||||
)
|
||||
|
||||
return diffset.render_patchset(_parsed, source_ref=source_ref_id, target_ref=target_ref_id)
|
||||
|
||||
|
||||
@async_task
|
||||
@adopt_for_celery
|
||||
def repo_size(extras):
|
||||
|
|
|
|||
|
|
@ -620,6 +620,21 @@ class DbManage(object):
|
|||
Session().add(ghost_user)
|
||||
Session().commit()
|
||||
|
||||
def create_ai_user(self):
|
||||
if not User.get_by_username(User.AI_USER):
|
||||
log.info("creating ai user")
|
||||
ai_user = User()
|
||||
ai_user.name = "RhodeCode Helper"
|
||||
ai_user.username = User.AI_USER
|
||||
ai_user.lastname = "User"
|
||||
ai_user.strict_creation_check = False
|
||||
ai_user.email = User.AI_USER_EMAIL
|
||||
ai_user.active = False
|
||||
ai_user.description = "System user"
|
||||
ai_user.is_new_user = False
|
||||
Session().add(ai_user)
|
||||
Session().commit()
|
||||
|
||||
def create_default_user(self):
|
||||
log.info("creating default user")
|
||||
# create default user for handling default permissions.
|
||||
|
|
|
|||
37
rhodecode/lib/dbmigrate/versions/119_version_5_8_0.py
Normal file
37
rhodecode/lib/dbmigrate/versions/119_version_5_8_0.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from rhodecode.lib.dbmigrate.versions import _reset_base
|
||||
from rhodecode.model import meta, init_model_encryption
|
||||
from rhodecode.model.db import User
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
"""
|
||||
Upgrade operations go here.
|
||||
Don't create your own engine; bind migrate_engine to your metadata
|
||||
"""
|
||||
_reset_base(migrate_engine)
|
||||
|
||||
from rhodecode.lib.dbmigrate.schema import db_4_20_0_1 as db
|
||||
|
||||
init_model_encryption(db)
|
||||
|
||||
UserModel: User = db.User
|
||||
|
||||
username = "ai_rhodecode"
|
||||
if not User.get_by_username(username):
|
||||
ghost_user = UserModel()
|
||||
ghost_user.name = "RhodeCode Helper"
|
||||
ghost_user.username = username
|
||||
ghost_user.lastname = "User"
|
||||
ghost_user.strict_creation_check = False
|
||||
ghost_user.email = "ai@rhodecode.org"
|
||||
ghost_user.active = False
|
||||
ghost_user.description = "System user"
|
||||
ghost_user.is_new_user = False
|
||||
|
||||
session = meta.Session()
|
||||
session.add(ghost_user)
|
||||
session.commit()
|
||||
|
||||
|
||||
def downgrade(migrate_engine):
|
||||
pass
|
||||
|
|
@ -1035,7 +1035,7 @@ def link_to_user(author, length=0, **kwargs):
|
|||
if length:
|
||||
display_person = shorter(display_person, length)
|
||||
|
||||
if user and user.username not in [user.DEFAULT_USER, user.GHOST_USER]:
|
||||
if user and user.username not in [user.DEFAULT_USER, user.GHOST_USER, user.AI_USER]:
|
||||
return link_to(escape(display_person), route_path("user_profile", username=user.username), **kwargs)
|
||||
else:
|
||||
return escape(display_person)
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@ class SimpleVCS(object):
|
|||
auth_user = user_obj.AuthUser()
|
||||
if (
|
||||
user_obj
|
||||
and user_obj.username not in [User.DEFAULT_USER, User.GHOST_USER]
|
||||
and user_obj.username not in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]
|
||||
and user_obj.user_data.get("force_password_change")
|
||||
):
|
||||
reason = "password change required"
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ def command(
|
|||
dbmanage.create_settings(opts)
|
||||
dbmanage.create_default_user()
|
||||
dbmanage.create_ghost_user()
|
||||
dbmanage.create_ai_user()
|
||||
dbmanage.create_admin_and_prompt()
|
||||
dbmanage.create_permissions()
|
||||
dbmanage.populate_default_permissions()
|
||||
|
|
|
|||
|
|
@ -841,6 +841,7 @@ def create_test_database(test_path, config):
|
|||
dbmanage.create_settings(dbmanage.config_prompt(test_path))
|
||||
dbmanage.create_default_user()
|
||||
dbmanage.create_ghost_user()
|
||||
dbmanage.create_ai_user()
|
||||
dbmanage.create_test_admin_and_users()
|
||||
dbmanage.create_permissions()
|
||||
dbmanage.populate_default_permissions()
|
||||
|
|
@ -875,8 +876,8 @@ def create_test_repositories(test_path, config):
|
|||
|
||||
|
||||
def password_changed(auth_user, session):
|
||||
# Never report password change in case of default user or anonymous user.
|
||||
if auth_user.username in [User.DEFAULT_USER, User.GHOST_USER] or auth_user.user_id is None:
|
||||
# Never report password change in case of default user, anonymous user or system user.
|
||||
if auth_user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER] or auth_user.user_id is None:
|
||||
return False
|
||||
|
||||
password_hash = md5(safe_bytes(auth_user.password)) if auth_user.password else None
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def display_user_sort(obj):
|
|||
of all other resources
|
||||
"""
|
||||
|
||||
if obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
return "#####"
|
||||
prefix = PERMISSION_TYPE_SORT.get(obj.permission.split(".")[-1], "")
|
||||
extra_sort_num = "1" # default
|
||||
|
|
@ -618,7 +618,9 @@ class User(Base, BaseModel):
|
|||
DEFAULT_USER = "default"
|
||||
DEFAULT_USER_EMAIL = "anonymous@rhodecode.org"
|
||||
GHOST_USER = "ghost"
|
||||
AI_USER = "ai_rhodecode"
|
||||
GHOST_USER_EMAIL = "ghost@rhodecode.org"
|
||||
AI_USER_EMAIL = "ai@rhodecode.org"
|
||||
DEFAULT_GRAVATAR_URL = "https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}"
|
||||
RECOVERY_CODES_COUNT = 10
|
||||
|
||||
|
|
@ -1286,6 +1288,10 @@ class User(Base, BaseModel):
|
|||
def get_ghost_user(cls, cache=False, refresh=False):
|
||||
return cls._get_system_user(username=cls.GHOST_USER, cache=cache, refresh=refresh)
|
||||
|
||||
@classmethod
|
||||
def get_ai_user(cls, cache=False, refresh=False):
|
||||
return cls._get_system_user(username=cls.AI_USER, cache=cache, refresh=refresh)
|
||||
|
||||
@classmethod
|
||||
def get_default_user(cls, cache=False, refresh=False):
|
||||
return cls._get_system_user(username=cls.DEFAULT_USER, cache=cache, refresh=refresh)
|
||||
|
|
@ -3047,7 +3053,7 @@ class RepoGroup(Base, BaseModel):
|
|||
@classmethod
|
||||
def get_user_personal_repo_group(cls, user_id):
|
||||
user = User.get(user_id)
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
return None
|
||||
|
||||
return cls.query().filter(cls.personal == true()).filter(cls.user == user).order_by(cls.group_id.asc()).first()
|
||||
|
|
|
|||
|
|
@ -666,3 +666,15 @@ def IssueTrackerPatternsForm(localizer):
|
|||
chained_validators = [v.ValidPattern(localizer)]
|
||||
|
||||
return _IssueTrackerPatternsForm
|
||||
|
||||
|
||||
def AiSettingsForm():
|
||||
class _AiSettingsForm(formencode.Schema):
|
||||
allow_extra_fields = True
|
||||
rhodecode_ai_features_enabled = v.StringBoolean(if_missing=False)
|
||||
rhodecode_ai_model = v.UnicodeString(strip=True, required=True)
|
||||
rhodecode_ai_model_version = v.UnicodeString(strip=True, required=True)
|
||||
rhodecode_ai_api_key = v.UnicodeString(strip=True)
|
||||
rhodecode_ai_code_review_instructions = v.UnicodeString(strip=True)
|
||||
|
||||
return _AiSettingsForm
|
||||
|
|
|
|||
|
|
@ -688,8 +688,8 @@ class RepoModel(BaseModel):
|
|||
if member_type == "user":
|
||||
member_name = User.get(member_id).username
|
||||
|
||||
if member_name == User.GHOST_USER:
|
||||
raise ValueError("Modify user permissions for ghost user is not allowed.")
|
||||
if member_name in [User.GHOST_USER, User.AI_USER]:
|
||||
raise ValueError("Modify user permissions for %s user is not allowed." % member_name)
|
||||
|
||||
if member_name == User.DEFAULT_USER:
|
||||
# NOTE(dan): detect if we changed permissions for default user
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ class RepoGroupModel(BaseModel):
|
|||
elif isinstance(_obj, Repository):
|
||||
# private repos will not allow to change the default
|
||||
# permissions using recursive mode
|
||||
if _obj.private and _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if _obj.private and _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
log.debug("Skipping private repo %s for user %s", _obj, _user_obj)
|
||||
return
|
||||
|
||||
|
|
@ -380,8 +380,8 @@ class RepoGroupModel(BaseModel):
|
|||
self.revoke_user_permission(repo_group=_obj, user=_user_obj)
|
||||
elif isinstance(_obj, Repository):
|
||||
# private repos will not allow to change the default
|
||||
# permissions using recursive mode, also there's no revocation fo default user, just update
|
||||
if _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
# permissions using recursive mode, also there's no revocation for default user, just update
|
||||
if _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
log.debug("Skipping private repo %s for user %s", _obj, _user_obj)
|
||||
return
|
||||
RepoModel().revoke_user_permission(repo=_obj, user=_user_obj)
|
||||
|
|
@ -426,8 +426,8 @@ class RepoGroupModel(BaseModel):
|
|||
member_obj = User.get(member_id)
|
||||
member_name = member_obj.username
|
||||
is_repo_group = isinstance(obj, RepoGroup) and obj == repo_group
|
||||
if is_repo_group and member_name == User.GHOST_USER:
|
||||
raise ValueError("Modify user permissions for ghost user is not allowed.")
|
||||
if is_repo_group and member_name in [User.GHOST_USER, User.AI_USER]:
|
||||
raise ValueError("Modify user permissions for %s user is not allowed." % member_name)
|
||||
|
||||
if is_repo_group and member_name == User.DEFAULT_USER:
|
||||
# NOTE(dan): detect if we changed permissions for default user
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class UserModel(BaseModel):
|
|||
qry = (
|
||||
User.query()
|
||||
.filter(User.active == true())
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER]))
|
||||
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]))
|
||||
)
|
||||
if cache:
|
||||
qry = qry.options(FromCache("sql_cache_short", "get_active_users"))
|
||||
|
|
@ -180,7 +180,7 @@ class UserModel(BaseModel):
|
|||
|
||||
def update_user(self, user, skip_attrs=None, **kwargs):
|
||||
user = self._get_user(user)
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
raise DefaultUserException(
|
||||
"You can't edit this user (`%(username)s`) since it's "
|
||||
"crucial for entire application" % {"username": user.username}
|
||||
|
|
@ -363,8 +363,8 @@ class UserModel(BaseModel):
|
|||
new_user = user
|
||||
edit = True
|
||||
|
||||
# we're not allowed to edit default user
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
# we're not allowed to edit default user or system user
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
raise DefaultUserException(
|
||||
"You can't edit this user (`%(username)s`) since it's "
|
||||
"crucial for entire application" % {"username": user.username}
|
||||
|
|
@ -576,7 +576,7 @@ class UserModel(BaseModel):
|
|||
user = self._get_user(user)
|
||||
|
||||
try:
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
|
||||
if user.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER]:
|
||||
raise DefaultUserException("You can't remove this user since it's crucial for entire application")
|
||||
|
||||
if delete_reviewer:
|
||||
|
|
|
|||
162
rhodecode/templates/admin/settings/settings_ai.mako
Normal file
162
rhodecode/templates/admin/settings/settings_ai.mako
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">${_('Ai Configuration')}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
${h.secure_form(h.route_path('admin_settings_ai_update'), id='ai_features_form', request=request)}
|
||||
<div class="fields">
|
||||
|
||||
<div class="field" id="ai-features-toggle">
|
||||
<div class="label label-checkbox">
|
||||
<label for="rhodecode_ai_features_enabled">${_('Enable AI features')}:</label>
|
||||
</div>
|
||||
<div class="checkboxes">
|
||||
${h.checkbox('rhodecode_ai_features_enabled',value=True, checked=c.ai_features_enabled==True)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="model">
|
||||
<div class="label label">
|
||||
<label for="model">${_('Model')}</label>
|
||||
</div>
|
||||
<select id="rhodecode_ai_model" name="rhodecode_ai_model">
|
||||
% for m in c.model_map:
|
||||
<option value="${m}">${m}</option>
|
||||
% endfor
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="model_version_slot"></div>
|
||||
|
||||
<div class="field" id="rhodecode_ai_api_key">
|
||||
<div class="label label">
|
||||
<label for="rhodecode_ai_api_key">${_('Api key')}</label>
|
||||
</div>
|
||||
<div class="input">
|
||||
${h.text('rhodecode_ai_api_key', c.api_key, size=30)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label-textarea">
|
||||
<label for="rhodecode_ai_code_review_instructions">${_('Code Review Instructions')}:</label>
|
||||
</div>
|
||||
<div class="large textarea-full text-area" style="width: 60%;">
|
||||
${h.textarea('rhodecode_ai_code_review_instructions', c.ai_instructions, rows=14)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${h.submit('save',_('Save'),class_="btn")}
|
||||
</div>
|
||||
${h.end_form()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!
|
||||
import json
|
||||
%>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
function selector(_id) {
|
||||
$(_id).select2({
|
||||
containerCssClass: 'drop-menu',
|
||||
dropdownCssClass: 'drop-menu-dropdown',
|
||||
dropdownAutoWidth: true,
|
||||
minimumResultsForSearch: -1
|
||||
});
|
||||
}
|
||||
|
||||
const MODEL_MAP = ${json.dumps(c.model_map) | n};
|
||||
|
||||
const SELECTED_MODEL = ${json.dumps(getattr(c, 'selected_ai_model', None)) | n};
|
||||
const SELECTED_VERSION = ${json.dumps(getattr(c, 'selected_ai_model_version', None)) | n};
|
||||
|
||||
const $model = $('#rhodecode_ai_model');
|
||||
const $slot = $('#model_version_slot');
|
||||
const $enable = $('#rhodecode_ai_features_enabled');
|
||||
const $form = $('#ai_features_form');
|
||||
|
||||
function renderVersionField(model) {
|
||||
const versions = MODEL_MAP[model] || [];
|
||||
|
||||
$slot.empty();
|
||||
|
||||
if (!versions.length) {
|
||||
setAIFieldsActive($enable.is(':checked'));
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="field" id="model_version">
|
||||
<div class="label label">
|
||||
<label for="rhodecode_ai_model_version">${_('Model Version')}</label>
|
||||
</div>
|
||||
<select id="rhodecode_ai_model_version" name="rhodecode_ai_model_version"></select>
|
||||
</div>
|
||||
`;
|
||||
$slot.append(html);
|
||||
|
||||
const $ver = $('#rhodecode_ai_model_version');
|
||||
for (const v of versions) {
|
||||
const isSelected = (model === SELECTED_MODEL) && (String(v) === String(SELECTED_VERSION));
|
||||
$ver.append(new Option(String(v), String(v), isSelected, isSelected));
|
||||
}
|
||||
|
||||
if (!$ver.find('option:selected').length && versions.length) {
|
||||
$ver.prop('selectedIndex', 0);
|
||||
}
|
||||
|
||||
selector('#rhodecode_ai_model_version');
|
||||
$ver.trigger('change');
|
||||
|
||||
setAIFieldsActive($enable.is(':checked'));
|
||||
}
|
||||
|
||||
function setAIFieldsActive(enabled) {
|
||||
const $affectedFields = $('.fields .field').not('#ai-features-toggle');
|
||||
|
||||
$affectedFields.toggleClass('is-disabled', !enabled);
|
||||
|
||||
|
||||
$affectedFields
|
||||
.find('input, textarea, button')
|
||||
.prop('disabled', !enabled);
|
||||
|
||||
$affectedFields
|
||||
.find('select')
|
||||
.prop('disabled', !enabled)
|
||||
.trigger('change.select2');
|
||||
}
|
||||
|
||||
renderVersionField($model.val());
|
||||
|
||||
$model.on('change', function () {
|
||||
renderVersionField(this.value);
|
||||
});
|
||||
|
||||
selector('#rhodecode_ai_model');
|
||||
|
||||
if (SELECTED_MODEL && $model.find('option[value="' + SELECTED_MODEL.replace(/"/g, '\\"') + '"]').length) {
|
||||
$model.val(SELECTED_MODEL).trigger('change.select2');
|
||||
}
|
||||
|
||||
renderVersionField($model.val());
|
||||
|
||||
$enable.on('change', function () {
|
||||
setAIFieldsActive(this.checked);
|
||||
});
|
||||
|
||||
$form.submit(function () {
|
||||
let $f = $(this);
|
||||
|
||||
let $tmpEnabled = $f.find(':disabled');
|
||||
$tmpEnabled.prop('disabled', false);
|
||||
|
||||
setTimeout(function () {
|
||||
// immediately re-enable disable property
|
||||
$tmpEnabled.prop('disabled', true);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue