feature: implements locking feature for AI review button; adds detailed audit logging

This commit is contained in:
ievgenii.v 2025-10-29 16:31:07 +02:00
parent 32952f105b
commit 6e3aa8ed6b
10 changed files with 279 additions and 44 deletions

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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": ""},

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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())

View file

@ -714,11 +714,18 @@
${_('AI')}
</span>
</div>
<div class="btn-group btn-group-actions" style="margin-top: 10px">
<a id="ai-code-review" class="btn btn-primary no-margin">
${_('Start AI code review')}
</a>
</div>
% if not c.ai_code_review_state:
<div class="btn-group btn-group-actions" style="margin-top: 10px">
<button id="ai-code-review" class="btn btn-primary no-margin">
${_('Start AI code review')}
</button>
</div>
% else:
<div style="margin-top: 10px">
<span>${_('AI review already %s' %c.ai_code_review_state)}</span>
</div>
% endif
##<input id="ai-code-review" class="btn" name="save" type="submit" value="${_('Start AI code review')}">
</div>
</div>