diff --git a/requirements.txt b/requirements.txt index 395b6ee3..a390d2ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/rhodecode/__init__.py b/rhodecode/__init__.py index de5bc964..aac458ae 100644 --- a/rhodecode/__init__.py +++ b/rhodecode/__init__.py @@ -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" diff --git a/rhodecode/api/tests/test_get_users.py b/rhodecode/api/tests/test_get_users.py index 75e198cf..b86aebcd 100644 --- a/rhodecode/api/tests/test_get_users.py +++ b/rhodecode/api/tests/test_get_users.py @@ -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() ) diff --git a/rhodecode/api/views/user_api.py b/rhodecode/api/views/user_api.py index a1f4a802..73d4c8fa 100644 --- a/rhodecode/api/views/user_api.py +++ b/rhodecode/api/views/user_api.py @@ -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)) diff --git a/rhodecode/apps/_base/__init__.py b/rhodecode/apps/_base/__init__.py index fbb85e22..eba715e6 100644 --- a/rhodecode/apps/_base/__init__.py +++ b/rhodecode/apps/_base/__init__.py @@ -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", diff --git a/rhodecode/apps/_base/navigation.py b/rhodecode/apps/_base/navigation.py index a9add214..a9ae29e6 100644 --- a/rhodecode/apps/_base/navigation.py +++ b/rhodecode/apps/_base/navigation.py @@ -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"), diff --git a/rhodecode/apps/admin/__init__.py b/rhodecode/apps/admin/__init__.py index 88ce4960..3cf1b7e3 100644 --- a/rhodecode/apps/admin/__init__.py +++ b/rhodecode/apps/admin/__init__.py @@ -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, diff --git a/rhodecode/apps/admin/tests/test_admin_users.py b/rhodecode/apps/admin/tests/test_admin_users.py index 71d2169b..bc8c9fe2 100644 --- a/rhodecode/apps/admin/tests/test_admin_users.py +++ b/rhodecode/apps/admin/tests/test_admin_users.py @@ -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 diff --git a/rhodecode/apps/admin/views/ai.py b/rhodecode/apps/admin/views/ai.py new file mode 100644 index 00000000..8c938a51 --- /dev/null +++ b/rhodecode/apps/admin/views/ai.py @@ -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 diff --git a/rhodecode/apps/admin/views/users.py b/rhodecode/apps/admin/views/users.py index 113c99b7..1dee66b8 100644 --- a/rhodecode/apps/admin/views/users.py +++ b/rhodecode/apps/admin/views/users.py @@ -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() diff --git a/rhodecode/apps/ai_agents/__init__.py b/rhodecode/apps/ai_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/apps/ai_agents/ai_service.py b/rhodecode/apps/ai_agents/ai_service.py new file mode 100644 index 00000000..f91d8d0b --- /dev/null +++ b/rhodecode/apps/ai_agents/ai_service.py @@ -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}") diff --git a/rhodecode/apps/ai_agents/ai_settings.py b/rhodecode/apps/ai_agents/ai_settings.py new file mode 100644 index 00000000..e32f0947 --- /dev/null +++ b/rhodecode/apps/ai_agents/ai_settings.py @@ -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 diff --git a/rhodecode/apps/ai_agents/models/__init__.py b/rhodecode/apps/ai_agents/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/apps/ai_agents/models/base.py b/rhodecode/apps/ai_agents/models/base.py new file mode 100644 index 00000000..4fe58e5f --- /dev/null +++ b/rhodecode/apps/ai_agents/models/base.py @@ -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) diff --git a/rhodecode/apps/ai_agents/models/claude.py b/rhodecode/apps/ai_agents/models/claude.py new file mode 100644 index 00000000..86e46de9 --- /dev/null +++ b/rhodecode/apps/ai_agents/models/claude.py @@ -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 diff --git a/rhodecode/apps/ai_agents/models/gemini.py b/rhodecode/apps/ai_agents/models/gemini.py new file mode 100644 index 00000000..c274e61f --- /dev/null +++ b/rhodecode/apps/ai_agents/models/gemini.py @@ -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, + } diff --git a/rhodecode/apps/ai_agents/models/gpt.py b/rhodecode/apps/ai_agents/models/gpt.py new file mode 100644 index 00000000..a1e52229 --- /dev/null +++ b/rhodecode/apps/ai_agents/models/gpt.py @@ -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") diff --git a/rhodecode/apps/home/views.py b/rhodecode/apps/home/views.py index 6687f038..f3386a76 100644 --- a/rhodecode/apps/home/views.py +++ b/rhodecode/apps/home/views.py @@ -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: diff --git a/rhodecode/apps/repository/views/repo_pull_requests.py b/rhodecode/apps/repository/views/repo_pull_requests.py index 04ea7962..7ec27be7 100644 --- a/rhodecode/apps/repository/views/repo_pull_requests.py +++ b/rhodecode/apps/repository/views/repo_pull_requests.py @@ -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") diff --git a/rhodecode/apps/user_profile/tests/test_users.py b/rhodecode/apps/user_profile/tests/test_users.py index ddabbf2b..ab1d1b43 100644 --- a/rhodecode/apps/user_profile/tests/test_users.py +++ b/rhodecode/apps/user_profile/tests/test_users.py @@ -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) diff --git a/rhodecode/apps/user_profile/views.py b/rhodecode/apps/user_profile/views.py index dc91ecc3..66691a1d 100644 --- a/rhodecode/apps/user_profile/views.py +++ b/rhodecode/apps/user_profile/views.py @@ -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) diff --git a/rhodecode/lib/audit_logger.py b/rhodecode/lib/audit_logger.py index c503f6c0..51f928a7 100644 --- a/rhodecode/lib/audit_logger.py +++ b/rhodecode/lib/audit_logger.py @@ -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": ""}, diff --git a/rhodecode/lib/auth.py b/rhodecode/lib/auth.py index 7158d3fb..5f1e6d02 100644 --- a/rhodecode/lib/auth.py +++ b/rhodecode/lib/auth.py @@ -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 diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 943e16bf..07233de8 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -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): diff --git a/rhodecode/lib/db_manage.py b/rhodecode/lib/db_manage.py index b33a3cf5..60c36519 100644 --- a/rhodecode/lib/db_manage.py +++ b/rhodecode/lib/db_manage.py @@ -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. diff --git a/rhodecode/lib/dbmigrate/versions/119_version_5_8_0.py b/rhodecode/lib/dbmigrate/versions/119_version_5_8_0.py new file mode 100644 index 00000000..4c87896b --- /dev/null +++ b/rhodecode/lib/dbmigrate/versions/119_version_5_8_0.py @@ -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 diff --git a/rhodecode/lib/helpers.py b/rhodecode/lib/helpers.py index 3acc9e1e..30a7cbe4 100644 --- a/rhodecode/lib/helpers.py +++ b/rhodecode/lib/helpers.py @@ -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) diff --git a/rhodecode/lib/middleware/simplevcs.py b/rhodecode/lib/middleware/simplevcs.py index eb38042c..0c83e557 100644 --- a/rhodecode/lib/middleware/simplevcs.py +++ b/rhodecode/lib/middleware/simplevcs.py @@ -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" diff --git a/rhodecode/lib/rc_commands/setup_rc.py b/rhodecode/lib/rc_commands/setup_rc.py index fd342794..25cc3e94 100644 --- a/rhodecode/lib/rc_commands/setup_rc.py +++ b/rhodecode/lib/rc_commands/setup_rc.py @@ -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() diff --git a/rhodecode/lib/utils.py b/rhodecode/lib/utils.py index b3116434..f58e76e9 100644 --- a/rhodecode/lib/utils.py +++ b/rhodecode/lib/utils.py @@ -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 diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index fc83a1ab..b71b41f7 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -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() diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py index bcf35235..8e1afc95 100644 --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -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 diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py index ef65b9f5..d8e90201 100644 --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -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 diff --git a/rhodecode/model/repo_group.py b/rhodecode/model/repo_group.py index e4493a0c..af6b3b5d 100644 --- a/rhodecode/model/repo_group.py +++ b/rhodecode/model/repo_group.py @@ -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 diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index ae7f2cf9..c367b3ed 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -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: diff --git a/rhodecode/templates/admin/settings/settings_ai.mako b/rhodecode/templates/admin/settings/settings_ai.mako new file mode 100644 index 00000000..4965f51c --- /dev/null +++ b/rhodecode/templates/admin/settings/settings_ai.mako @@ -0,0 +1,162 @@ +