Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions odoo_repository/models/odoo_module_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def init(self):
CREATE UNIQUE INDEX IF NOT EXISTS odoo_module_branch_uniq_null
ON odoo_module_branch (module_id, branch_id)
WHERE repository_id IS NULL;
"""
""",
# PostgreSQL >= 15 (with NULLS NOT DISTINCT)
# """
# CREATE UNIQUE INDEX odoo_module_branch_uniq
Expand Down Expand Up @@ -296,21 +296,30 @@ def _compute_dependency_level(self):
else 0
)

def _get_recursive_dependencies(self, domain=None):
def _get_recursive_dependencies(self, domain=None, _visited=None):
"""Return all dependencies recursively.

A domain can be applied to restrict the modules to return, e.g:

>>> mod._get_recursive_dependencies([("org_id", "=", "OCA")])

"""
# NOTE: Circular dependencies are allowed
if not domain:
domain = []
dependencies = self.dependency_ids.filtered_domain(domain)
if _visited is None:
_visited = set()
if self.id in _visited:
return self.browse()
_visited.add(self.id)
# Apply domain and exclude self
dependencies = (self.dependency_ids - self).filtered_domain(domain)
dep_ids = set(dependencies.ids)
for dep in dependencies:
dep_ids |= set(
dep._get_recursive_dependencies().filtered_domain(domain).ids
dep._get_recursive_dependencies(domain, _visited)
.filtered_domain(domain)
.ids
)
return self.browse(dep_ids)

Expand Down
1 change: 1 addition & 0 deletions odoo_repository/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from . import test_sync_node
from . import test_odoo_module_branch
from . import test_oca_repository_synchronizer
from . import test_odoo_module_branch_recursive_dependencies
63 changes: 28 additions & 35 deletions odoo_repository/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# Copyright 2026 Sébastien Alix
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import os
import pathlib
import re
import tempfile
Expand All @@ -24,35 +23,35 @@ def setUpClass(cls):
cls.env["ir.config_parameter"].set_param(
"odoo_repository_storage_path", cls.repositories_path
)
cls._apply_git_config()

def setUp(self):
super().setUp()
self.repo_name = pathlib.Path(self.repo_upstream_path).parts[-1]
self.org = self.env["odoo.repository.org"].create({"name": self.fork_org})
self.odoo_repository = self.env["odoo.repository"].create(
# org and repository
cls.repo_name = pathlib.Path(cls.repo_upstream_path).parts[-1]
cls.org = cls.env["odoo.repository.org"].create({"name": cls.fork_org})
cls.odoo_repository = cls.env["odoo.repository"].create(
{
"org_id": self.org.id,
"name": self.repo_name,
"repo_url": self.repo_upstream_path,
"clone_url": self.repo_upstream_path,
"org_id": cls.org.id,
"name": cls.repo_name,
"repo_url": cls.repo_upstream_path,
"clone_url": cls.repo_upstream_path,
"repo_type": "github",
}
)
# branch1
self.branch1_name = self.source1.split("/")[1]
self.branch = (
self.env["odoo.branch"]
cls.branch1_name = cls.source1.split("/")[1]
cls.branch = (
cls.env["odoo.branch"]
.with_context(active_test=False)
.search([("name", "=", self.branch1_name)])
.search([("name", "=", cls.branch1_name)])
)
if not self.branch:
self.branch = self.env["odoo.branch"].create(
if not cls.branch:
cls.branch = cls.env["odoo.branch"].create(
{
"name": self.branch1_name,
"name": cls.branch1_name,
}
)
self.branch.active = True
cls.branch.active = True

def setUp(self):
super().setUp()
# branch2
self.branch2_name = self.source2.split("/")[1]
self.branch2 = (
Expand All @@ -73,15 +72,6 @@ def setUp(self):
self.module_name = self.addon
self.module_branch_model = self.env["odoo.module.branch"]

@classmethod
def _apply_git_config(cls):
"""Configure git (~/.gitconfig) if no config file exists."""
git_cfg = pathlib.Path(os.path.expanduser("~/.gitconfig"))
if git_cfg.exists():
return
os.system("git config --global user.email 'test@example.com'")
os.system("git config --global user.name 'test'")

def _patch_github_class(self):
# Patch helper method part of 'odoo_repository' module
self.patcher = patch("odoo.addons.odoo_repository.utils.github.request")
Expand Down Expand Up @@ -138,21 +128,24 @@ def _run_odoo_repository_action_scan(self, branch_id, force=False):
branch_ids=[branch_id], force=force
)

def _create_odoo_module(self, name):
return self.env["odoo.module"].create({"name": name})
@classmethod
def _create_odoo_module(cls, name):
return cls.env["odoo.module"].create({"name": name})

def _create_odoo_repository_branch(self, repo, branch, **values):
@classmethod
def _create_odoo_repository_branch(cls, repo, branch, **values):
vals = {
"repository_id": repo.id,
"branch_id": branch.id,
}
vals.update(values)
return self.env["odoo.repository.branch"].create(vals)
return cls.env["odoo.repository.branch"].create(vals)

def _create_odoo_module_branch(self, module, branch, **values):
@classmethod
def _create_odoo_module_branch(cls, module, branch, **values):
vals = {
"module_id": module.id,
"branch_id": branch.id,
}
vals.update(values)
return self.env["odoo.module.branch"].create(vals)
return cls.env["odoo.module.branch"].create(vals)
85 changes: 51 additions & 34 deletions odoo_repository/tests/odoo_repo_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import io
import os
import shutil
import tempfile
import threading
Expand Down Expand Up @@ -29,34 +30,43 @@ def setUpClass(cls):
cls.target3 = "origin/18.0"
cls.addon = "my_module"
cls.target_addon = "my_module_renamed"

def setUp(self):
super().setUp()
# Create a temporary Git repository
self.repo_upstream_path = self._get_upstream_repository_path()
self.addon_path = Path(self.repo_upstream_path) / self.addon
self.manifest_path = self.addon_path / "__manifest__.py"
cls._apply_git_config()
cls.repo_upstream_path = cls._get_upstream_repository_path()
cls.addon_path = Path(cls.repo_upstream_path) / cls.addon
cls.manifest_path = cls.addon_path / "__manifest__.py"
# By cloning the first repository this will set an 'origin' remote
self.repo_path = self._clone_tmp_git_repository(self.repo_upstream_path)
self._add_fork_remote(self.repo_path)
cls.repo_path = cls._clone_tmp_git_repository(cls.repo_upstream_path)
cls._add_fork_remote(cls.repo_path)

@classmethod
def _apply_git_config(cls):
"""Configure git (~/.gitconfig) if no config file exists."""
git_cfg = Path(os.path.expanduser("~/.gitconfig"))
if git_cfg.exists():
return
os.system("git config --global user.email 'test@example.com'")
os.system("git config --global user.name 'test'")

def _get_upstream_repository_path(self) -> Path:
@classmethod
def _get_upstream_repository_path(cls) -> Path:
"""Returns the path of upstream repository.

Generate the upstream git repository or re-use the one put in cache if any.
"""
if hasattr(cache, "archive_data") and cache.archive_data:
# Unarchive the repository from memory
repo_path = self._unarchive_upstream_repository(cache.archive_data)
repo_path = cls._unarchive_upstream_repository(cache.archive_data)
else:
# Prepare and archive the repository in memory
repo_path = self._create_tmp_git_repository()
addon_path = repo_path / self.addon
self._fill_git_repository(repo_path, addon_path)
cache.archive_data = self._archive_upstream_repository(repo_path)
repo_path = cls._create_tmp_git_repository()
addon_path = repo_path / cls.addon
cls._fill_git_repository(repo_path, addon_path)
cache.archive_data = cls._archive_upstream_repository(repo_path)
return repo_path

def _archive_upstream_repository(self, repo_path: Path) -> bytes:
@classmethod
def _archive_upstream_repository(cls, repo_path: Path) -> bytes:
"""Archive the repository located at `repo_path`.

Returns binary value of the archive.
Expand All @@ -70,7 +80,8 @@ def _archive_upstream_repository(self, repo_path: Path) -> bytes:
zipf.write(file_path, arcname)
return zip_buffer.getvalue()

def _unarchive_upstream_repository(self, archive_data: bytes) -> Path:
@classmethod
def _unarchive_upstream_repository(cls, archive_data: bytes) -> Path:
"""Unarchive the repository contained in `archive_data`.

Returns path of repository.
Expand All @@ -83,50 +94,54 @@ def _unarchive_upstream_repository(self, archive_data: bytes) -> Path:
if path.is_dir() and ".git" in path.name:
return path.parent

def _create_tmp_git_repository(self) -> Path:
@classmethod
def _create_tmp_git_repository(cls) -> Path:
"""Create a temporary Git repository to run tests."""
repo_path = tempfile.mkdtemp()
git.Repo.init(repo_path)
return Path(repo_path)

def _clone_tmp_git_repository(self, upstream_path: Path) -> Path:
@classmethod
def _clone_tmp_git_repository(cls, upstream_path: Path) -> Path:
repo_path = tempfile.mkdtemp()
git.Repo.clone_from(upstream_path, repo_path)
return Path(repo_path)

def _fill_git_repository(self, repo_path: Path, addon_path: Path):
@classmethod
def _fill_git_repository(cls, repo_path: Path, addon_path: Path):
"""Create branches with some content in the Git repository."""
repo = git.Repo(repo_path)
# Commit a file in '15.0'
branch1 = self.source1.split("/")[1]
branch1 = cls.source1.split("/")[1]
repo.git.checkout("--orphan", branch1)
self._create_module(addon_path)
cls._create_module(addon_path)
repo.index.add(addon_path)
commit = repo.index.commit(f"[ADD] {self.addon}")
commit = repo.index.commit(f"[ADD] {cls.addon}")
# Port the commit from 15.0 to 16.0
branch2 = self.source2.split("/")[1]
branch2 = cls.source2.split("/")[1]
repo.git.checkout("--orphan", branch2)
repo.git.reset("--hard")
# Some git operations do not appear to be atomic, so a delay is added
# to allow them to complete
time.sleep(1)
repo.git.cherry_pick(commit.hexsha)
# Create an empty branch 17.0
branch3 = self.target2.split("/")[1]
branch3 = cls.target2.split("/")[1]
repo.git.checkout("--orphan", branch3)
repo.git.reset("--hard")
repo.git.commit("-m", "Init", "--allow-empty")
# Port the commit from 15.0 to 18.0
branch4 = self.target3.split("/")[1]
branch4 = cls.target3.split("/")[1]
repo.git.checkout("--orphan", branch4)
repo.git.reset("--hard")
time.sleep(1)
repo.git.cherry_pick(commit.hexsha)
# Rename the module on 18.0
repo.git.mv(self.addon, self.target_addon)
repo.git.commit("-m", f"Rename {self.addon} to {self.target_addon}")
repo.git.mv(cls.addon, cls.target_addon)
repo.git.commit("-m", f"Rename {cls.addon} to {cls.target_addon}")

def _create_module(self, module_path: Path):
@classmethod
def _create_module(cls, module_path: Path):
manifest_lines = [
"# Copyright 2026 Sébastien Alix\n",
"# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)\n",
Expand All @@ -148,13 +163,15 @@ def _create_module(self, module_path: Path):
with open(manifest_path, "w") as manifest:
manifest.writelines(manifest_lines)

def _add_fork_remote(self, repo_path: Path):
@classmethod
def _add_fork_remote(cls, repo_path: Path):
repo = git.Repo(repo_path)
# We do not really care about the remote URL here, re-use origin one
repo.create_remote(self.fork_org, repo.remotes.origin.url)
repo.create_remote(cls.fork_org, repo.remotes.origin.url)

def tearDown(self):
super().tearDown()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
# Clean up the Git repository
shutil.rmtree(self.repo_upstream_path)
shutil.rmtree(self.repo_path)
shutil.rmtree(cls.repo_upstream_path)
shutil.rmtree(cls.repo_path)
Loading