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
20 changes: 8 additions & 12 deletions odoo_repository/tests/common.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2024 Camptocamp SA
# Copyright 2026 Sébastien Alix
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import os
Expand All @@ -8,16 +9,16 @@
from unittest.mock import patch

import git
from oca_port.tests.common import CommonCase

from odoo.tests.common import TransactionCase

from .odoo_repo_mixin import OdooRepoMixin

class Common(TransactionCase, CommonCase):

class Common(TransactionCase, OdooRepoMixin):
@classmethod
def setUpClass(cls):
super().setUpClass()
CommonCase.setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.repositories_path = tempfile.mkdtemp()
cls.env["ir.config_parameter"].set_param(
Expand All @@ -27,9 +28,6 @@ def setUpClass(cls):

def setUp(self):
super().setUp()
# Leverage the existing test class from 'oca_port' to bootstrap
# temporary git repositories to run tests
CommonCase.setUp(self)
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(
Expand Down Expand Up @@ -85,13 +83,11 @@ def _apply_git_config(cls):
os.system("git config --global user.name 'test'")

def _patch_github_class(self):
res = super()._patch_github_class()
# Patch helper method part of 'odoo_repository' module as well
self.patcher2 = patch("odoo.addons.odoo_repository.utils.github.request")
github_request = self.patcher2.start()
# Patch helper method part of 'odoo_repository' module
self.patcher = patch("odoo.addons.odoo_repository.utils.github.request")
github_request = self.patcher.start()
github_request.return_value = {}
self.addCleanup(self.patcher2.stop)
return res
self.addCleanup(self.patcher.stop)

def _update_module_version_on_branch(self, branch, version):
"""Change module version on a given branch, and commit the change."""
Expand Down
160 changes: 160 additions & 0 deletions odoo_repository/tests/odoo_repo_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright 2026 Sébastien Alix
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import io
import shutil
import tempfile
import threading
import time
import unittest
import zipfile
from pathlib import Path

import git

cache = threading.local()


class OdooRepoMixin(unittest.TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.upstream_org = "ORG"
cls.fork_org = "FORK"
cls.repo_name = "test"
cls.source1 = "origin/15.0"
cls.source2 = "origin/16.0"
cls.target1 = "origin/16.0"
cls.target2 = "origin/17.0"
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"
# 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)

def _get_upstream_repository_path(self) -> 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)
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)
return repo_path

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

Returns binary value of the archive.
"""
# Create in-memory zip archive
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w") as zipf:
for file_path in repo_path.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(repo_path)
zipf.write(file_path, arcname)
return zip_buffer.getvalue()

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

Returns path of repository.
"""
temp_dir = tempfile.mkdtemp()
with zipfile.ZipFile(io.BytesIO(archive_data), "r") as zip_ref:
zip_ref.extractall(temp_dir)
# Look for the repo directory and return its path
for path in Path(temp_dir).rglob("*"):
if path.is_dir() and ".git" in path.name:
return path.parent

def _create_tmp_git_repository(self) -> 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:
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):
"""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]
repo.git.checkout("--orphan", branch1)
self._create_module(addon_path)
repo.index.add(addon_path)
commit = repo.index.commit(f"[ADD] {self.addon}")
# Port the commit from 15.0 to 16.0
branch2 = self.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]
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]
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}")

def _create_module(self, module_path: Path):
manifest_lines = [
"# Copyright 2026 Sébastien Alix\n",
"# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)\n",
"{\n",
' "name": "Test",\n',
' "version": "1.0.0",\n',
' "category": "Test Module",\n',
' "author": "Odoo Community Association (OCA)",\n',
' "website": "https://github.com/OCA/module-composition-analysis",\n',
' "license": "AGPL-3",\n',
' "depends": ["base"],\n',
' "data": [],\n',
' "demo": [],\n',
' "installable": True,\n',
"}\n",
]
module_path.mkdir(parents=True, exist_ok=True)
manifest_path = module_path / "__manifest__.py"
with open(manifest_path, "w") as manifest:
manifest.writelines(manifest_lines)

def _add_fork_remote(self, 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)

def tearDown(self):
super().tearDown()
# Clean up the Git repository
shutil.rmtree(self.repo_upstream_path)
shutil.rmtree(self.repo_path)
2 changes: 1 addition & 1 deletion odoo_repository/tests/test_base_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def _init_scanner(self, **params):
kwargs = {
"org": self.fork_org,
"name": self.repo_name,
"clone_url": self.repo_upstream_path,
"clone_url": str(self.repo_upstream_path),
"branches": [
self.branch1_name,
self.branch2_name,
Expand Down
5 changes: 3 additions & 2 deletions odoo_repository/tests/test_odoo_repository_scan.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2024 Camptocamp SA
# Copyright 2026 Sébastien Alix
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import fields
Expand Down Expand Up @@ -31,7 +32,7 @@ def test_action_scan_basic(self):
self.assertEqual(module_branch.category_id.name, "Test Module")
self.assertItemsEqual(
module_branch.author_ids.mapped("name"),
["Odoo Community Association (OCA)", "Camptocamp"],
["Odoo Community Association (OCA)"],
)
self.assertFalse(module_branch.specific)
self.assertEqual(module_branch.dependency_ids.module_name, "base")
Expand Down Expand Up @@ -233,7 +234,7 @@ def test_action_scan_uninstallable_module(self):
self.assertEqual(module_branch.category_id.name, "Test Module")
self.assertItemsEqual(
module_branch.author_ids.mapped("name"),
["Odoo Community Association (OCA)", "Camptocamp"],
["Odoo Community Association (OCA)"],
)
self.assertFalse(module_branch.specific)
# No dependencies
Expand Down
2 changes: 1 addition & 1 deletion odoo_repository/tests/test_repository_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def _init_scanner(self, **params):
kwargs = {
"org": self.org.name,
"name": self.repo_name,
"clone_url": self.repo_upstream_path,
"clone_url": str(self.repo_upstream_path),
"version": self.branch.name,
"branch": self.branch.name,
"addons_paths_data": [
Expand Down