diff --git a/rhodecode/__init__.py b/rhodecode/__init__.py index aac458ae..2ec2a184 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__ = 119 # defines current db version for migrations +__dbversion__ = 120 # defines current db version for migrations __license__ = "AGPLv3, and Commercial License" __author__ = "RhodeCode GmbH" __url__ = "https://code.rhodecode.com" diff --git a/rhodecode/apps/home/views.py b/rhodecode/apps/home/views.py index 4bcf4e46..ab6a8623 100644 --- a/rhodecode/apps/home/views.py +++ b/rhodecode/apps/home/views.py @@ -80,7 +80,7 @@ class HomeView(BaseAppView, DataGridAppView): def maybe_skip_user(usr): if (skip_default_user and usr["username"] == UserModel.cls.DEFAULT_USER - or usr["username"] in [UserModel.cls.GHOST_USER, UserModel.cls.AI_USER]): + or usr["username"] == UserModel.cls.GHOST_USER): return False return True diff --git a/rhodecode/apps/repository/__init__.py b/rhodecode/apps/repository/__init__.py index de1e3d19..82538c6d 100644 --- a/rhodecode/apps/repository/__init__.py +++ b/rhodecode/apps/repository/__init__.py @@ -833,7 +833,7 @@ def includeme(config): ) config.add_view( RepoPullRequestsView, - attr="start_ai_code_review", + attr="add_ai_user_to_reviewers", route_name="pullrequest_start_ai_code_review", request_method="POST", renderer="json_ext", diff --git a/rhodecode/apps/repository/views/repo_pull_requests.py b/rhodecode/apps/repository/views/repo_pull_requests.py index 30f923ac..dfd8171c 100644 --- a/rhodecode/apps/repository/views/repo_pull_requests.py +++ b/rhodecode/apps/repository/views/repo_pull_requests.py @@ -18,6 +18,7 @@ import logging import collections +from copy import deepcopy import formencode import formencode.htmlfill @@ -362,10 +363,7 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): c.pr_broadcast_channel = channelstream.pr_channel(pull_request) rc_settings = SettingsModel() - ai_setting = rc_settings.get_setting_by_name("ai_features_enabled") - c.ai_enabled = False - if ai_setting: - c.ai_enabled = ai_setting.app_settings_value + c.ai_enabled = rc_settings.get_setting_by_name("ai_features_enabled") _new_state = { "created": PullRequest.STATE_CREATED, @@ -447,6 +445,11 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): c.renderer = pull_request_at_ver.description_renderer or c.renderer c.pull_request_latest = pull_request_latest + c.ai_code_review_state = None + ai_pr_state = pull_request.ai_code_review_state + if ai_pr_state: + c.ai_code_review_state = ai_pr_state.get("review_state", None) + # inject latest version latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest) c.versions = versions + [latest_ver] @@ -1289,8 +1292,9 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): @LoginRequired() @HasRepoPermissionAnyDecorator("repository.read", "repository.write", "repository.admin") - def start_ai_code_review(self): + def add_ai_user_to_reviewers(self): _ = self.request.translate + c = self.load_default_context() redirect_url = None pull_request = PullRequest.get_or_404(self.request.matchdict["pull_request_id"]) @@ -1300,14 +1304,41 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): h.flash(message, category="error") return {"response": True, "redirect_url": redirect_url} - log.debug("AI code review: started") - redirect_url = h.route_path( "pullrequest_show", repo_name=self.db_repo_name, pull_request_id=pull_request.pull_request_id, ) + self._try_start_ai_code_review(c, _, pull_request, add_existing_users=True) + self._set_ai_pr_state_pending(pull_request) + + return {"response": True, "redirect_url": redirect_url} + + def _set_ai_pr_state_pending(self, pull_request: PullRequest): + ai_state = deepcopy(pull_request.ai_code_review_state) + + if not ai_state: + ai_state = { + "review_state": "pending" + } + else: + ai_state["review_state"] = "pending" + + pull_request.ai_code_review_state = ai_state + Session().commit() + + def _try_start_ai_code_review(self, c, _, pull_request: PullRequest, add_existing_users: bool = False) -> bool: + already_finished = False + ai_state = pull_request.ai_code_review_state + + if ai_state: + already_finished = ai_state.get("reviewed", False) + + if already_finished: + log.warning(f"Code review already finished.") + return False + admin_permissions = ( "repository.admin", "hg.admin", @@ -1317,7 +1348,40 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): ) if is_admin: + ai_user = User.get_ai_user() log.debug("Initiating code review for pull request id: %s", pull_request.pull_request_id) + + reviewers_data = [ + ( + ai_user.user_id, + ["adds system ai user for review"], + False, + PullRequestReviewers.ROLE_REVIEWER, + pull_request.reviewer_data + ) + ] + + if add_existing_users: + current_reviewers = PullRequestReviewers.get_pull_request_reviewers( + pull_request.pull_request_id, role=PullRequestReviewers.ROLE_REVIEWER + ) + for existing_reviewer in current_reviewers: + reviewers_data.append( + ( + existing_reviewer.user_id, + existing_reviewer.reasons, + existing_reviewer.mandatory, + existing_reviewer.role, + existing_reviewer.rule_data, + ) + ) + + PullRequestModel().update_reviewers( + pull_request, reviewers_data, self._rhodecode_db_user + ) + + Session().commit() + 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") @@ -1330,9 +1394,8 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView): msg, category="warning", ) - - return {"response": True, "redirect_url": redirect_url} - + return False + return True @LoginRequired() @NotAnonymous() @@ -1366,7 +1429,20 @@ 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: + success = self._try_start_ai_code_review(c, _, pull_request) + if not success: + del members_[i] + + break + self._update_reviewers( c, pull_request, diff --git a/rhodecode/lib/audit_logger.py b/rhodecode/lib/audit_logger.py index 51f928a7..d8a6c2f4 100644 --- a/rhodecode/lib/audit_logger.py +++ b/rhodecode/lib/audit_logger.py @@ -37,6 +37,7 @@ AI_MODEL_VERSION = "ai_model_version" 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": ""}, + "ai.code-review.error": {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/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index c3ab950a..70ec856e 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -23,6 +23,7 @@ by celery daemon import os import time +from copy import deepcopy from logging import Logger from typing import Any @@ -53,7 +54,7 @@ from rhodecode.model.db import ( RepoGroup, User, ScheduleEntry, - PullRequest, + PullRequest, RhodeCodeSetting, ) from rhodecode.model.permission import PermissionModel from rhodecode.model.pull_request import PullRequestModel @@ -497,42 +498,70 @@ def start_ai_code_review(pull_request_id): rc_settings = SettingsModel() ai_setting = rc_settings.get_setting_by_name("ai_features_enabled") ai_enabled = False + ai_user = User.get_ai_user() + if ai_setting: ai_enabled = ai_setting.app_settings_value if not ai_enabled: - log.info("AI code review is disabled.") + err_msg = f"AI code review is disabled. Pull request id: {pull_request_id}" + log.warning(err_msg) + audit_logger.store( + "ai.code-review.error", + user=ai_user, + action_data={ + audit_logger.PR_ID: pull_request_id, + audit_logger.AI_MODEL: None, + "error": True, + "error_message": err_msg, + }, + ) 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) + err_msg = f"Pull request id: {pull_request_id} not found, review not possible." + log.error(err_msg) + _set_ai_pr_state(pull_request, "error") + audit_logger.store( + "ai.code-review.error", + user=ai_user, + action_data={ + audit_logger.PR_ID: pull_request_id, + audit_logger.AI_MODEL: None, + "error": True, + "error_message": err_msg, + }, + ) 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) + settings_valid, err_msg = _validate_settings(rc_settings) if not settings_valid: + log.error(err_msg) + _set_ai_pr_state(pull_request, "error") + audit_logger.store( + "ai.code-review.error", + user=ai_user, + action_data={ + audit_logger.PR_ID: pull_request_id, + audit_logger.AI_MODEL: None, + "error": True, + "error_message": err_msg, + }, + repo=pull_request.target_repo, + ) 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.") + _set_ai_pr_state(pull_request, "error") return - 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, @@ -545,6 +574,42 @@ def start_ai_code_review(pull_request_id): commit=True, ) + if not pull_request.revisions: + err_msg = f"Current revision not found for pull request id: {pull_request_id} not found, review not possible." + log.error(err_msg) + _set_ai_pr_state(pull_request, "error") + audit_logger.store( + "ai.code-review.finish", + user=ai_user, + action_data={ + audit_logger.PR_ID: pull_request_id, + audit_logger.AI_MODEL: ai_model_version, + "error": True, + "error_message": err_msg, + }, + repo=pull_request.target_repo, + ) + return + + if pull_request.is_state_changing(): + err_msg = f"Pull request id: {pull_request_id} is in state changing, review not possible." + log.error(err_msg) + _set_ai_pr_state(pull_request, "error") + audit_logger.store( + "ai.code-review.finish", + user=ai_user, + action_data={ + audit_logger.PR_ID: pull_request_id, + audit_logger.AI_MODEL: ai_model_version, + "error": True, + "error_message": err_msg, + }, + repo=pull_request.target_repo, + ) + return + + diffset = _get_diffset(pull_request) + try: service = get_ai_service( api_key=ai_api_key, @@ -552,8 +617,18 @@ def start_ai_code_review(pull_request_id): api_model_version_name=ai_model_version, ) - response = service.code_review(diffset, instructions=instructions) - _add_comments(response, pull_request, log, ai_user) + instructions = rc_settings.get_setting_by_name("ai_code_review_instructions") + if instructions: + instructions = instructions.app_settings_value.split("\r\n") + else: + log.warning("instructions not set, using default instructions.") + instructions = service.DEFAULT_BASIC_REVIEW_POINTS + + # response = service.code_review(diffset, instructions=instructions) + # _add_comments(response, pull_request, log, ai_user) + + _set_ai_pr_state(pull_request, "finished") + audit_logger.store( "ai.code-review.finish", user=ai_user, @@ -567,6 +642,7 @@ def start_ai_code_review(pull_request_id): ) except AIServiceError as e: log.error("AI service error: %s", e) + _set_ai_pr_state(pull_request, "error") audit_logger.store( "ai.code-review.finish", user=ai_user, @@ -580,6 +656,20 @@ def start_ai_code_review(pull_request_id): ) +def _set_ai_pr_state(pull_request: PullRequest, state: str | None): + ai_state = deepcopy(pull_request.ai_code_review_state) + + if not ai_state: + ai_state = { + "review_state": state + } + else: + ai_state["review_state"] = state + + pull_request.ai_code_review_state = ai_state + Session().commit() + + def _add_comments(response: Response, pull_request: PullRequest, log: Logger | Any, ai_user: User): log.debug( "Response from AI service: %s", @@ -616,17 +706,21 @@ def insert_comment(line_number: int, text: str, f_path: str, pull_request: PullR ) -def _validate_settings(rc_settings, log): +def _validate_settings(rc_settings): + message = None + valid = True + if not rc_settings.get_setting_by_name("ai_api_key"): - log.info("AI API key is not set, review not possible.") - return False + message = "AI API key is not set, review not possible." + valid = False if not rc_settings.get_setting_by_name("ai_model"): - log.info("AI model is not set, review not possible.") - return False + message = "AI model is not set, review not possible." + valid = 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 + message = "AI model version is not set, review not possible." + valid = False + + return valid, message def _get_diffset( diff --git a/rhodecode/lib/dbmigrate/versions/120_version_5_8_0.py b/rhodecode/lib/dbmigrate/versions/120_version_5_8_0.py new file mode 100644 index 00000000..52f52a0e --- /dev/null +++ b/rhodecode/lib/dbmigrate/versions/120_version_5_8_0.py @@ -0,0 +1,48 @@ +import json +import logging + +from psycopg2.errorcodes import DUPLICATE_COLUMN +from sqlalchemy import * +from sqlalchemy.engine import reflection + +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from rhodecode.lib.dbmigrate.versions import _reset_base +from rhodecode.lib.jsonalchemy import MutationObj, JsonType +from rhodecode.model import meta, init_model_encryption + + +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) + + context = MigrationContext.configure(migrate_engine.connect()) + op = Operations(context) + inspector = inspect(migrate_engine) + + pr_tables = [db.PullRequest.__table__, db.PullRequestVersion.__table__] + for pr_table in pr_tables: + existing_columns = [col["name"] for col in inspector.get_columns(pr_table.name)] + new_column_name = "ai_code_review_state" + if new_column_name not in existing_columns: + with op.batch_alter_table(pr_table.name) as batch_op: + new_column = Column( + new_column_name, + MutationObj.as_mutable( + JsonType(dialect_map=dict(mysql=UnicodeText(16384))), + ), + default=dict, + ) + batch_op.add_column(new_column) + + +def downgrade(migrate_engine): + pass diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index b71b41f7..9eef9ddd 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -4479,6 +4479,14 @@ class _PullRequestBase(BaseModel): default=dict, ) + ai_code_review_state = Column( + "ai_code_review_state", + MutationObj.as_mutable( + JsonType(dialect_map=dict(mysql=UnicodeText(DEFAULT_JSON_OBJ_SIZE))) + ), + default=dict + ) + @property def reviewer_data_json(self): return str_json(self.reviewer_data) diff --git a/rhodecode/model/pull_request.py b/rhodecode/model/pull_request.py index 5cd4669f..7066c75f 100644 --- a/rhodecode/model/pull_request.py +++ b/rhodecode/model/pull_request.py @@ -1532,9 +1532,10 @@ class PullRequestModel(BaseModel): raise ValueError("This pull request is closed") reviewers = {} - for user_id, reasons, mandatory, role, rules in reviewer_data: + for user_id, reasons, mandatory, role, _ in reviewer_data: if isinstance(user_id, (int, str)): user_id = self._get_user(user_id).user_id + reviewers[user_id] = {"reasons": reasons, "mandatory": mandatory, "role": role} reviewers_ids = set(reviewers.keys()) diff --git a/rhodecode/templates/pullrequests/pullrequest_show.mako b/rhodecode/templates/pullrequests/pullrequest_show.mako index 5a2728a0..b4a0614e 100644 --- a/rhodecode/templates/pullrequests/pullrequest_show.mako +++ b/rhodecode/templates/pullrequests/pullrequest_show.mako @@ -714,11 +714,18 @@ ${_('AI')} -
+ % if not c.ai_code_review_state: +