Merge pull request !2784 from rhodecode-enterprise-ce feature/RCCE-136_ghost-pull-requests-prevents-repo-from-deleting

Changes from branch: Feature/RCCE 136 ghost pull requests prevents repo from deleting
This commit is contained in:
Andrii Verbytskyi 2025-08-04 09:22:45 +00:00
commit 74a02ee5fd
15 changed files with 243 additions and 95 deletions

View file

@ -0,0 +1,36 @@
|RCE| 5.7.0 |RNS|
-----------------
Release Date
^^^^^^^^^^^^
- 2025-08-15
New Features
^^^^^^^^^^^^
- squash: The pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator.
- pull request: Introduced a new checkbox in the pull request UI, allowing users to select the option to close the branch after merging. This feature is exclusive to the Enterprise Edition (EE).
- user: It is now possible to remove users from the system. When a user is deleted, all of their assets will automatically be reassigned to a new system user, Ghost User.
- active directory: Added the ability to sync active and inactive users from LDAP/Active Directory. This feature must be enabled in the LDAP authentication plugin by an administrator, is available only for Active Directory, and is exclusive to the Enterprise Edition (EE).
General
^^^^^^^
Security
^^^^^^^^
Performance
^^^^^^^^^^^
Fixes
^^^^^
- ssh: Fixed an issue where the app.service_api.token field could be left empty in the configuration. The application will now automatically populate this field if it is missing.
Upgrade notes
^^^^^^^^^^^^^

View file

@ -19,6 +19,8 @@
import mock
import pytest
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel
from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert_ok, crash
@ -27,6 +29,9 @@ from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert
class TestApiDeleteRepo(object):
def test_api_delete_repo(self, backend):
repo = backend.create_repo()
repo.private = True
Session().commit()
repo_name = repo.repo_name
id_, params = build_data(
self.apikey,
@ -38,6 +43,46 @@ class TestApiDeleteRepo(object):
expected = {"msg": "Deleted repository `%s`" % (repo_name,), "success": True}
assert_ok(id_, expected, given=response.body)
@pytest.mark.parametrize(
"fork_action, expected_fork_msg",
[
("detach", "Detached 1 forks"),
("delete", "Deleted 1 forks"),
],
)
def test_api_delete_repo_with_forks(self, backend, fork_action, expected_fork_msg):
repo = backend.create_repo()
repo.private = True
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
assert fork.parent is not None
repo_name = repo.repo_name
id_, params = build_data(
self.apikey,
"delete_repo",
repoid=repo.repo_name,
forks=fork_action,
)
response = api_call(self.app, params)
expected = {
"msg": "Deleted repository `%s` %s; NOTE: 'forks' option will be deprecated."
% (repo_name, expected_fork_msg),
"success": True,
}
assert_ok(id_, expected, given=response.body)
self._validate_fork_db_state(fork_action, fork_name)
def _validate_fork_db_state(self, fork_action, fork_name):
fork = Repository.get_by_repo_name(repo_name=fork_name)
if fork_action == "detach":
assert fork.parent is None
elif fork_action == "delete":
assert fork is None
def test_api_delete_repo_by_non_admin(self, backend, user_regular):
repo = backend.create_repo(cur_user=user_regular.username)
repo_name = repo.repo_name

View file

@ -48,7 +48,7 @@ def assert_ok(id_, expected, given):
expected = jsonify({"id": id_, "error": None, "result": expected})
assert expected == given
assert expected == given, "Expected: %s, Given: %s" % (expected, given)
def assert_error(id_, expected, given):

View file

@ -50,7 +50,7 @@ from rhodecode.model.comment import CommentsModel
from rhodecode.model.db import Session, ChangesetStatus, RepositoryField, Repository, RepoGroup, ChangesetComment
from rhodecode.model.permission import PermissionModel
from rhodecode.model.pull_request import PullRequestModel
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel, RepoList
from rhodecode.model.settings import SettingsModel, VcsSettingsModel
from rhodecode.model import validation_schema
@ -1360,17 +1360,18 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
validate_repo_permissions(apiuser, repoid, repo, _perms)
try:
handle_forks = Optional.extract(forks)
str_fork_action = Optional.extract(forks)
_forks_msg = ""
_forks = [f for f in repo.forks]
if handle_forks == "detach":
if str_fork_action == "detach":
_forks_msg = " " + f"Detached {len(_forks)} forks"
elif handle_forks == "delete":
elif str_fork_action == "delete":
_forks_msg = " " + f"Deleted {len(_forks)} forks"
elif _forks:
raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks")
old_data = repo.get_api_data()
RepoModel().delete(repo, forks=forks)
RepoModel().delete(repo, forks_action=ForksAction(str_fork_action))
repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name)
@ -1378,7 +1379,12 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
ScmModel().mark_for_invalidation(repo_name, delete=True)
Session().commit()
return {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True}
res_message = {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True}
if str_fork_action:
deprecation_warning = "; NOTE: 'forks' option will be deprecated."
res_message["msg"] += deprecation_warning
return res_message
except Exception:
log.exception("Exception occurred while trying to delete repo")
raise JSONRPCError(f"failed to delete repository `{repo_name}`")

View file

@ -21,7 +21,8 @@ from unittest.mock import patch, MagicMock
from rhodecode.apps.repository.views.repo_settings_advanced import RepoSettingsAdvancedView
from rhodecode.lib.str_utils import safe_str
from rhodecode.model.db import Repository
from rhodecode.model.repo import RepoModel
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.tests import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator
from rhodecode.tests.fixtures.rc_fixture import Fixture
from rhodecode.tests.utils import repo_on_filesystem
@ -61,7 +62,7 @@ class TestAdminRepoSettingsAdvanced(object):
response.mustcontain(opt)
fixture.destroy_repo(target_repo, forks="detach")
fixture.destroy_repo(target_repo, forks_action=ForksAction.DETACH)
@pytest.mark.backends("hg", "git")
def test_set_fork_of_other_type_repo(self, autologin_user, backend, csrf_token):
@ -97,7 +98,10 @@ class TestAdminRepoSettingsAdvanced(object):
@pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator)
def test_advanced_repo_delete(self, autologin_user, backend, suffix, csrf_token):
repo = backend.create_repo(name_suffix=suffix)
repo = backend.create_repo(name_suffix=suffix, private_repo=True)
repo.private = True
Session().commit()
repo_name = repo.repo_name
repo_name_str = safe_str(repo.repo_name)

View file

@ -31,7 +31,7 @@ from rhodecode.lib.utils2 import safe_int
from rhodecode.lib.vcs import RepositoryError
from rhodecode.model.db import Session, UserFollowing, User, Repository
from rhodecode.model.permission import PermissionModel
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel
log = logging.getLogger(__name__)
@ -56,6 +56,9 @@ class RepoSettingsAdvancedView(RepoAppView):
c = self.load_default_context()
c.active = "advanced"
c.fork_links = self._resolve_repo_forks_links()
c.pr_link = h.route_path("pullrequest_show_all", repo_name=self.db_repo_name)
c.default_user_id = User.get_default_user_id()
c.in_public_journal = (
UserFollowing.query()
@ -80,6 +83,14 @@ class RepoSettingsAdvancedView(RepoAppView):
return self._get_template_context(c)
def _resolve_repo_forks_links(self):
def resolve_fork_link(repo):
for fork in repo.forks:
yield h.route_path("repo_summary", repo_name=fork.repo_name)
yield from resolve_fork_link(fork)
return list(resolve_fork_link(self.db_repo))
@LoginRequired()
@HasRepoPermissionAnyDecorator("repository.admin")
@CSRFRequired()
@ -124,11 +135,6 @@ class RepoSettingsAdvancedView(RepoAppView):
because of attached forks or other errors.
"""
_ = self.request.translate
handle_forks = self.request.POST.get("forks", None)
if handle_forks == "detach_forks":
handle_forks = "detach"
elif handle_forks == "delete_forks":
handle_forks = "delete"
repo_advanced_url = h.route_path("edit_repo_advanced", repo_name=self.db_repo_name, _anchor="advanced-delete")
try:
@ -137,14 +143,16 @@ class RepoSettingsAdvancedView(RepoAppView):
delete_cache = True
self._invalidate_remote_cache(delete=delete_cache)
RepoModel().delete(self.db_repo, forks=handle_forks)
delete_res = RepoModel().delete(self.db_repo)
_forks = self.db_repo.forks.count()
if _forks and handle_forks:
if handle_forks == "detach_forks":
h.flash(_("Detached %s forks") % _forks, category="success")
elif handle_forks == "delete_forks":
h.flash(_("Deleted %s forks") % _forks, category="success")
fork_action = delete_res.fork_action_result.action
fork_cnt = delete_res.fork_action_result.fork_count
if fork_cnt > 0 and fork_action is not None:
if fork_action is ForksAction.DETACH:
h.flash(_("Detached %s forks") % fork_cnt, category="success")
elif fork_action is ForksAction.DELETE:
h.flash(_("Deleted %s forks") % fork_cnt, category="success")
repo = audit_logger.RepoWrap(repo_id=None, repo_name=self.db_repo.repo_name)
audit_logger.store_web(

View file

@ -35,6 +35,10 @@ def initialize_ini_config_default_values_if_not_present(ini_path: str):
with open(ini_path, "w") as configfile:
updater.write(configfile)
if not ini_path or not os.path.exists(ini_path):
log.warning("Config file %s not found.", ini_path)
return
updater = ConfigUpdater()
updater.read(ini_path)

View file

@ -2583,10 +2583,10 @@ msgstr ""
msgid "Confirm to delete this repository"
msgstr ""
#: rhodecode/templates/admin/repos/repo_edit.html:310
#: rhodecode/templates/admin/repos/repo_edit.html:208
#, python-format
msgid "this repository has %s fork"
msgid_plural "this repository has %s forks"
msgid "Following fork will be %s"
msgid_plural "Following forks will be %s"
msgstr[0] ""
msgstr[1] ""

View file

@ -7567,7 +7567,7 @@ msgstr ""
msgid "Delete this repository"
msgstr ""
#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:242
#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:249
msgid "This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools."
msgstr ""

View file

@ -57,7 +57,6 @@ from rhodecode.model import meta
from rhodecode.model.db import Repository, User, RhodeCodeUi, UserLog, RepoGroup, UserGroup
from rhodecode.model.meta import Session
log = logging.getLogger(__name__)
REMOVED_REPO_PAT = re.compile(r"rm__\d{8}_\d{6}_\d{6}__.*")
@ -665,7 +664,7 @@ def repo2db_mapper(initial_repo_list, force_hooks_rebuild=False):
def repo2db_cleanup(skip_repos=None, skip_groups=None):
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.repo_group import RepoGroupModel
sa = meta.Session()
@ -688,7 +687,7 @@ def repo2db_cleanup(skip_repos=None, skip_groups=None):
if not instance:
log.debug("Removing non-existing repository found in db `%s`", db_repo_name)
try:
RepoModel(sa).delete(db_repo, forks="detach", fs_remove=False, call_events=False)
RepoModel(sa).delete(db_repo, forks_action=ForksAction.DETACH, fs_remove=False, call_events=False)
sa.commit()
removed.append(db_repo_name)
except Exception:

View file

@ -15,7 +15,7 @@
# This program is dual-licensed. If you wish to learn more about the
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
import itertools
import os
import re
import shutil
@ -23,6 +23,8 @@ import time
import logging
import traceback
import datetime
from dataclasses import dataclass, field
from enum import StrEnum, auto
from sqlalchemy.orm import aliased
from zope.cachedescriptors.property import Lazy as LazyProperty
@ -69,11 +71,32 @@ from rhodecode.model.db import (
UserLog,
)
from rhodecode.model.permission import PermissionModel
from rhodecode.model.settings import VcsSettingsModel
log = logging.getLogger(__name__)
class ForksAction(StrEnum):
DELETE = auto()
DETACH = auto()
UNKNOWN = auto()
@classmethod
def _missing_(cls, value):
return cls(cls.UNKNOWN)
@dataclass
class ForkActionResult:
fork_count: int = 0
action: ForksAction = None
@dataclass
class DeleteRepoResult:
is_success: bool
fork_action_result: ForkActionResult = field(default_factory=ForkActionResult)
class RepoModel(BaseModel):
cls = Repository
@ -750,47 +773,49 @@ class RepoModel(BaseModel):
raise
def delete(
self, repo, forks=None, pull_requests=None, artifacts=None, fs_remove=True, cur_user=None, call_events=True
):
self,
repo: Repository,
forks_action: ForksAction = None, # used only in API to enforce action
fs_remove: bool = True,
cur_user: User = None,
call_events: bool = True,
) -> DeleteRepoResult:
"""
Delete given repository, forks parameter defines what do do with
attached forks. Throws AttachedForksError if deleted repo has attached
forks
Delete a given repository.
Throws AttachedForksError if a deleted repo has attached forks
:param repo:
:param forks: str 'delete' or 'detach'
:param pull_requests: str 'delete' or None
:param artifacts: str 'delete' or None
:param forks_action:
NOTE: used only in API to enforce action;
If the repository is private, then forks will be removed.
If the repository is public, then forks will be detached.
:param fs_remove: remove(archive) repo from filesystem
"""
if not cur_user:
cur_user = getattr(get_current_rhodecode_user(), "username", None)
repo = self._get_repo(repo)
if not repo:
return False
return DeleteRepoResult(is_success=False)
if forks == "detach":
if not cur_user:
cur_user = getattr(get_current_rhodecode_user(), "username", None)
if forks_action is None or forks_action is ForksAction.UNKNOWN:
is_private = repo.private
forks_action = ForksAction.DELETE if is_private else ForksAction.DETACH
forks_cnt = repo.forks.count()
if forks_action is ForksAction.DETACH:
for r in repo.forks:
r.fork = None
self.sa.add(r)
elif forks == "delete":
elif forks_action is ForksAction.DELETE:
for r in repo.forks:
self.delete(r, forks="delete")
elif [f for f in repo.forks]:
self.delete(r, forks_action=forks_action)
elif repo.forks.first() is not None:
raise AttachedForksError()
# check for pull requests
pr_sources = repo.pull_requests_source
pr_targets = repo.pull_requests_target
if pull_requests != "delete" and (pr_sources or pr_targets):
raise AttachedPullRequestsError()
artifacts_objs = repo.artifacts
if artifacts == "delete":
for a in artifacts_objs:
self.sa.delete(a)
elif [a for a in artifacts_objs]:
raise AttachedArtifactsError()
self._delete_repo_related_assets(repo)
old_repo_dict = repo.get_dict()
if call_events:
@ -815,7 +840,17 @@ class RepoModel(BaseModel):
log.error(traceback.format_exc())
raise
return True
return DeleteRepoResult(
is_success=True, fork_action_result=ForkActionResult(action=forks_action, fork_count=forks_cnt)
)
def _delete_repo_related_assets(self, repo):
pr_sources = repo.pull_requests_source
pr_targets = repo.pull_requests_target
artifacts = repo.artifacts
logs = repo.logs
for obj_to_remove in itertools.chain(pr_sources, pr_targets, artifacts, logs):
self.sa.delete(obj_to_remove)
def grant_user_permission(self, repo, user, perm):
"""

View file

@ -200,50 +200,42 @@
<div class="panel-body">
${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=c.repo_name), request=request)}
<table class="display">
<% forks = len(c.fork_links) %>
<% delete_forks = c.rhodecode_db_repo.private %>
% if forks:
<tr>
<td>
${_ungettext('This repository has %s fork.', 'This repository has %s forks.', c.rhodecode_db_repo.forks.count()) % c.rhodecode_db_repo.forks.count()}
<td class="alert-${'error' if delete_forks else 'warning'}" style="padding: 10px">
${_ungettext('Following fork will be %s:', 'Following forks will be %s:', forks) % ('deleted' if delete_forks else 'detached')}
</td>
<td>
%if c.rhodecode_db_repo.forks.count():
<input type="radio" name="forks" value="detach_forks" checked="checked"/> <label for="forks">${_('Detach forks')}</label>
%endif
</td>
<td>
%if c.rhodecode_db_repo.forks.count():
<input type="radio" name="forks" value="delete_forks"/> <label for="forks">${_('Delete forks')}</label>
%endif
<ul>
% for fork in c.fork_links:
<li class="list-unstyled"><a href="${fork}" target="_blank" rel="noopener noreferrer">${fork.replace("/", "")} <span aria-hidden="true" style="font-size: 0.9em;">↗️</span></a></li>
% endfor
</ul>
</td>
</tr>
% endif
<% attached_prs = len(c.rhodecode_db_repo.pull_requests_source + c.rhodecode_db_repo.pull_requests_target) %>
% if c.rhodecode_db_repo.pull_requests_source or c.rhodecode_db_repo.pull_requests_target:
% if attached_prs:
<tr>
<td>
${_ungettext('This repository has %s attached pull request.', 'This repository has %s attached pull requests.', attached_prs) % attached_prs}
<br/>
<br/>
<strong>${_('Consider to archive this repository instead.')}</strong>
<td colspan="2" style="padding: 10px">
<a href="${c.pr_link}" target="_blank" rel="noopener noreferrer">
${_ungettext('This repository has %s attached pull request (open/close).', 'This repository has %s attached pull requests (open/close).', attached_prs) % attached_prs} <span aria-hidden="true" style="font-size: 0.9em;">↗️</span>
</a>
</td>
<td></td>
<td></td>
</tr>
% endif
<% attached_artifacts = len(c.rhodecode_db_repo.artifacts) %>
% if attached_artifacts:
<tr>
<td>
<td colspan="2">
${_ungettext('This repository has %s attached artifact.', 'This repository has %s attached artifacts.', attached_artifacts) % attached_artifacts}
<br/>
<br/>
<strong>${_('Consider to archive this repository instead.')}</strong>
</td>
<td></td>
<td></td>
</tr>
% endif
</table>
<div style="margin: 0 0 20px 0" class="fake-space"></div>
@ -259,7 +251,6 @@
${_('This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools.')}
</span>
</div>
${h.end_form()}
</div>
</div>

View file

@ -294,7 +294,7 @@ class Fixture(object):
return r
def destroy_repo(self, repo_name, **kwargs):
RepoModel().delete(repo_name, pull_requests="delete", artifacts="delete", **kwargs)
RepoModel().delete(repo_name, **kwargs)
Session().commit()
def destroy_repo_on_filesystem(self, repo_name):

View file

@ -293,7 +293,7 @@ def test_repo2db_mapper_adds_new_repos(request, backend):
for _repo in cleanup_repos:
del_result = RepoModel().delete(_repo, call_events=False)
Session().commit()
assert del_result is True
assert del_result.is_success is True
for _repo_group in cleanup_groups:
del_result = RepoGroupModel().delete(_repo_group, force_delete=True, call_events=False)

View file

@ -26,7 +26,7 @@ from rhodecode.lib.exceptions import AttachedForksError
from rhodecode.lib.utils import make_db_config
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel
@ -42,14 +42,34 @@ class TestRepoModel(object):
assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None
assert repo.repo_name not in repos
def test_remove_repo_raises_exc_when_attached_forks(self, backend):
def test_remove_repo_forks_are_detached(self, backend):
repo = backend.create_repo()
Session().commit()
backend.create_fork()
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
with pytest.raises(AttachedForksError):
RepoModel().delete(repo=repo)
assert fork.parent is not None
RepoModel().delete(repo=repo)
fork = Repository.get_by_repo_name(repo_name=fork_name)
assert fork.parent is None
def test_remove_repo_forks_are_deleted(self, backend):
repo = backend.create_repo()
repo.private = True
Session().commit()
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
assert fork.parent is not None
RepoModel().delete(repo=repo)
fork = Repository.get_by_repo_name(repo_name=fork_name)
assert fork is None
def test_remove_repo_delete_forks(self, backend):
repo = backend.create_repo()
@ -61,7 +81,7 @@ class TestRepoModel(object):
fork_of_fork = backend.create_fork()
Session().commit()
RepoModel().delete(repo=repo, forks="delete")
RepoModel().delete(repo=repo, forks_action=ForksAction.DELETE)
Session().commit()
assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None
@ -78,7 +98,7 @@ class TestRepoModel(object):
fork_of_fork = backend.create_fork()
Session().commit()
RepoModel().delete(repo=repo, forks="detach")
RepoModel().delete(repo=repo, forks_action=ForksAction.DETACH)
Session().commit()
assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None