From 802230ca4dd7cea3a956712ffa6f65f316f5415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 28 Aug 2023 12:57:27 +0200 Subject: [PATCH 001/160] Add 'odoo_repository' Module to build and maintain an exhaustive list of Odoo modules and their metadata: - dependencies between them - license - authors - maintainers - lines of code - ... --- odoo_repository/__init__.py | 1 + odoo_repository/__manifest__.py | 52 ++ odoo_repository/data/ir_config_parameter.xml | 9 + odoo_repository/data/ir_cron.xml | 19 + odoo_repository/data/odoo.repository.csv | 204 ++++++ odoo_repository/data/odoo_repository.xml | 20 + .../data/odoo_repository_addons_path.xml | 27 + odoo_repository/data/odoo_repository_org.xml | 14 + odoo_repository/data/queue_job.xml | 30 + odoo_repository/lib/__init__.py | 0 odoo_repository/lib/scanner.py | 596 ++++++++++++++++++ odoo_repository/models/__init__.py | 14 + odoo_repository/models/odoo_author.py | 20 + odoo_repository/models/odoo_branch.py | 25 + odoo_repository/models/odoo_license.py | 20 + odoo_repository/models/odoo_maintainer.py | 34 + odoo_repository/models/odoo_module.py | 24 + odoo_repository/models/odoo_module_branch.py | 410 ++++++++++++ .../models/odoo_module_category.py | 19 + .../models/odoo_module_dev_status.py | 19 + .../models/odoo_python_dependency.py | 20 + odoo_repository/models/odoo_repository.py | 243 +++++++ .../models/odoo_repository_addons_path.py | 40 ++ .../models/odoo_repository_branch.py | 63 ++ odoo_repository/models/odoo_repository_org.py | 27 + odoo_repository/models/ssh_key.py | 15 + odoo_repository/security/ir.model.access.csv | 21 + odoo_repository/static/description/icon.png | Bin 0 -> 26656 bytes odoo_repository/utils/__init__.py | 0 odoo_repository/utils/github.py | 28 + odoo_repository/utils/scanner.py | 83 +++ odoo_repository/views/menu.xml | 16 + odoo_repository/views/odoo_author.xml | 52 ++ odoo_repository/views/odoo_branch.xml | 42 ++ odoo_repository/views/odoo_license.xml | 38 ++ odoo_repository/views/odoo_maintainer.xml | 67 ++ odoo_repository/views/odoo_module.xml | 66 ++ odoo_repository/views/odoo_module_branch.xml | 174 +++++ .../views/odoo_module_category.xml | 38 ++ .../views/odoo_module_dev_status.xml | 38 ++ .../views/odoo_python_dependency.xml | 38 ++ odoo_repository/views/odoo_repository.xml | 125 ++++ .../views/odoo_repository_addons_path.xml | 47 ++ .../views/odoo_repository_branch.xml | 55 ++ odoo_repository/views/odoo_repository_org.xml | 52 ++ odoo_repository/views/ssh_key.xml | 55 ++ 46 files changed, 3000 insertions(+) create mode 100644 odoo_repository/__init__.py create mode 100644 odoo_repository/__manifest__.py create mode 100644 odoo_repository/data/ir_config_parameter.xml create mode 100644 odoo_repository/data/ir_cron.xml create mode 100644 odoo_repository/data/odoo.repository.csv create mode 100644 odoo_repository/data/odoo_repository.xml create mode 100644 odoo_repository/data/odoo_repository_addons_path.xml create mode 100644 odoo_repository/data/odoo_repository_org.xml create mode 100644 odoo_repository/data/queue_job.xml create mode 100644 odoo_repository/lib/__init__.py create mode 100644 odoo_repository/lib/scanner.py create mode 100644 odoo_repository/models/__init__.py create mode 100644 odoo_repository/models/odoo_author.py create mode 100644 odoo_repository/models/odoo_branch.py create mode 100644 odoo_repository/models/odoo_license.py create mode 100644 odoo_repository/models/odoo_maintainer.py create mode 100644 odoo_repository/models/odoo_module.py create mode 100644 odoo_repository/models/odoo_module_branch.py create mode 100644 odoo_repository/models/odoo_module_category.py create mode 100644 odoo_repository/models/odoo_module_dev_status.py create mode 100644 odoo_repository/models/odoo_python_dependency.py create mode 100644 odoo_repository/models/odoo_repository.py create mode 100644 odoo_repository/models/odoo_repository_addons_path.py create mode 100644 odoo_repository/models/odoo_repository_branch.py create mode 100644 odoo_repository/models/odoo_repository_org.py create mode 100644 odoo_repository/models/ssh_key.py create mode 100644 odoo_repository/security/ir.model.access.csv create mode 100644 odoo_repository/static/description/icon.png create mode 100644 odoo_repository/utils/__init__.py create mode 100644 odoo_repository/utils/github.py create mode 100644 odoo_repository/utils/scanner.py create mode 100644 odoo_repository/views/menu.xml create mode 100644 odoo_repository/views/odoo_author.xml create mode 100644 odoo_repository/views/odoo_branch.xml create mode 100644 odoo_repository/views/odoo_license.xml create mode 100644 odoo_repository/views/odoo_maintainer.xml create mode 100644 odoo_repository/views/odoo_module.xml create mode 100644 odoo_repository/views/odoo_module_branch.xml create mode 100644 odoo_repository/views/odoo_module_category.xml create mode 100644 odoo_repository/views/odoo_module_dev_status.xml create mode 100644 odoo_repository/views/odoo_python_dependency.xml create mode 100644 odoo_repository/views/odoo_repository.xml create mode 100644 odoo_repository/views/odoo_repository_addons_path.xml create mode 100644 odoo_repository/views/odoo_repository_branch.xml create mode 100644 odoo_repository/views/odoo_repository_org.xml create mode 100644 odoo_repository/views/ssh_key.xml diff --git a/odoo_repository/__init__.py b/odoo_repository/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/odoo_repository/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py new file mode 100644 index 00000000..f147feaa --- /dev/null +++ b/odoo_repository/__manifest__.py @@ -0,0 +1,52 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Odoo Repositories Data", + "summary": "Base module to host data collected from Odoo repositories.", + "version": "16.0.1.0.0", + "category": "Tools", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/TODO", + "data": [ + "security/ir.model.access.csv", + "data/ir_config_parameter.xml", + "data/ir_cron.xml", + "data/odoo_repository_org.xml", + "data/odoo_repository_addons_path.xml", + "data/odoo_repository.xml", + "data/odoo.repository.csv", + "data/queue_job.xml", + "views/menu.xml", + "views/ssh_key.xml", + "views/odoo_author.xml", + "views/odoo_branch.xml", + "views/odoo_license.xml", + "views/odoo_maintainer.xml", + "views/odoo_module.xml", + "views/odoo_module_branch.xml", + "views/odoo_module_category.xml", + "views/odoo_module_dev_status.xml", + "views/odoo_python_dependency.xml", + "views/odoo_repository.xml", + "views/odoo_repository_addons_path.xml", + "views/odoo_repository_branch.xml", + "views/odoo_repository_org.xml", + ], + "installable": True, + "application": True, + "depends": [ + # core + "base_sparse_field", + # OCA/queue + "queue_job", + ], + "external_dependencies": { + "python": [ + "gitpython", + "odoo-addons-analyzer", + # TODO to publish + # "odoo-repository-scanner" + ], + }, + "license": "AGPL-3", +} diff --git a/odoo_repository/data/ir_config_parameter.xml b/odoo_repository/data/ir_config_parameter.xml new file mode 100644 index 00000000..227feeba --- /dev/null +++ b/odoo_repository/data/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + odoo_repository.repositories_storage_path + + + diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml new file mode 100644 index 00000000..01d3fe50 --- /dev/null +++ b/odoo_repository/data/ir_cron.xml @@ -0,0 +1,19 @@ + + + + + + Odoo Repositories - Scanner + 1 + days + -1 + + + + + code + model.cron_scanner() + + + diff --git a/odoo_repository/data/odoo.repository.csv b/odoo_repository/data/odoo.repository.csv new file mode 100644 index 00000000..77463934 --- /dev/null +++ b/odoo_repository/data/odoo.repository.csv @@ -0,0 +1,204 @@ +id,org_id:id,name,repo_url,clone_url,repo_type +repo_oca_account_analytic,odoo_repository_org_oca,account-analytic,https://github.com/OCA/account-analytic,https://github.com/OCA/account-analytic,github +repo_oca_account_budgeting,odoo_repository_org_oca,account-budgeting,https://github.com/OCA/account-budgeting,https://github.com/OCA/account-budgeting,github +repo_oca_account_closing,odoo_repository_org_oca,account-closing,https://github.com/OCA/account-closing,https://github.com/OCA/account-closing,github +repo_oca_account_consolidation,odoo_repository_org_oca,account-consolidation,https://github.com/OCA/account-consolidation,https://github.com/OCA/account-consolidation,github +repo_oca_account_financial_reporting,odoo_repository_org_oca,account-financial-reporting,https://github.com/OCA/account-financial-reporting,https://github.com/OCA/account-financial-reporting,github +repo_oca_account_financial_tools,odoo_repository_org_oca,account-financial-tools,https://github.com/OCA/account-financial-tools,https://github.com/OCA/account-financial-tools,github +repo_oca_account_fiscal_rule,odoo_repository_org_oca,account-fiscal-rule,https://github.com/OCA/account-fiscal-rule,https://github.com/OCA/account-fiscal-rule,github +repo_oca_account_invoice_reporting,odoo_repository_org_oca,account-invoice-reporting,https://github.com/OCA/account-invoice-reporting,https://github.com/OCA/account-invoice-reporting,github +repo_oca_account_invoicing,odoo_repository_org_oca,account-invoicing,https://github.com/OCA/account-invoicing,https://github.com/OCA/account-invoicing,github +repo_oca_account_payment,odoo_repository_org_oca,account-payment,https://github.com/OCA/account-payment,https://github.com/OCA/account-payment,github +repo_oca_account_reconcile,odoo_repository_org_oca,account-reconcile,https://github.com/OCA/account-reconcile,https://github.com/OCA/account-reconcile,github +repo_oca_agreement,odoo_repository_org_oca,agreement,https://github.com/OCA/agreement,https://github.com/OCA/agreement,github +repo_oca_apps_store,odoo_repository_org_oca,apps-store,https://github.com/OCA/apps-store,https://github.com/OCA/apps-store,github +repo_oca_bank_payment,odoo_repository_org_oca,bank-payment,https://github.com/OCA/bank-payment,https://github.com/OCA/bank-payment,github +repo_oca_bank_statement_import,odoo_repository_org_oca,bank-statement-import,https://github.com/OCA/bank-statement-import,https://github.com/OCA/bank-statement-import,github +repo_oca_brand,odoo_repository_org_oca,brand,https://github.com/OCA/brand,https://github.com/OCA/brand,github +repo_oca_business_requirement,odoo_repository_org_oca,business-requirement,https://github.com/OCA/business-requirement,https://github.com/OCA/business-requirement,github +repo_oca_calendar,odoo_repository_org_oca,calendar,https://github.com/OCA/calendar,https://github.com/OCA/calendar,github +repo_oca_commission,odoo_repository_org_oca,commission,https://github.com/OCA/commission,https://github.com/OCA/commission,github +repo_oca_community_data_files,odoo_repository_org_oca,community-data-files,https://github.com/OCA/community-data-files,https://github.com/OCA/community-data-files,github +repo_oca_connector,odoo_repository_org_oca,connector,https://github.com/OCA/connector,https://github.com/OCA/connector,github +repo_oca_connector_accountedge,odoo_repository_org_oca,connector-accountedge,https://github.com/OCA/connector-accountedge,https://github.com/OCA/connector-accountedge,github +repo_oca_connector_cmis,odoo_repository_org_oca,connector-cmis,https://github.com/OCA/connector-cmis,https://github.com/OCA/connector-cmis,github +repo_oca_connector_ecommerce,odoo_repository_org_oca,connector-ecommerce,https://github.com/OCA/connector-ecommerce,https://github.com/OCA/connector-ecommerce,github +repo_oca_connector_infor,odoo_repository_org_oca,connector-infor,https://github.com/OCA/connector-infor,https://github.com/OCA/connector-infor,github +repo_oca_connector_interfaces,odoo_repository_org_oca,connector-interfaces,https://github.com/OCA/connector-interfaces,https://github.com/OCA/connector-interfaces,github +repo_oca_connector_jira,odoo_repository_org_oca,connector-jira,https://github.com/OCA/connector-jira,https://github.com/OCA/connector-jira,github +repo_oca_connector_lengow,odoo_repository_org_oca,connector-lengow,https://github.com/OCA/connector-lengow,https://github.com/OCA/connector-lengow,github +repo_oca_connector_lims,odoo_repository_org_oca,connector-lims,https://github.com/OCA/connector-lims,https://github.com/OCA/connector-lims,github +repo_oca_connector_magento,odoo_repository_org_oca,connector-magento,https://github.com/OCA/connector-magento,https://github.com/OCA/connector-magento,github +repo_oca_connector_magento_php_extension,odoo_repository_org_oca,connector-magento-php-extension,https://github.com/OCA/connector-magento-php-extension,https://github.com/OCA/connector-magento-php-extension,github +repo_oca_connector_odoo2odoo,odoo_repository_org_oca,connector-odoo2odoo,https://github.com/OCA/connector-odoo2odoo,https://github.com/OCA/connector-odoo2odoo,github +repo_oca_connector_prestashop,odoo_repository_org_oca,connector-prestashop,https://github.com/OCA/connector-prestashop,https://github.com/OCA/connector-prestashop,github +repo_oca_connector_redmine,odoo_repository_org_oca,connector-redmine,https://github.com/OCA/connector-redmine,https://github.com/OCA/connector-redmine,github +repo_oca_connector_sage,odoo_repository_org_oca,connector-sage,https://github.com/OCA/connector-sage,https://github.com/OCA/connector-sage,github +repo_oca_connector_salesforce,odoo_repository_org_oca,connector-salesforce,https://github.com/OCA/connector-salesforce,https://github.com/OCA/connector-salesforce,github +repo_oca_connector_spscommerce,odoo_repository_org_oca,connector-spscommerce,https://github.com/OCA/connector-spscommerce,https://github.com/OCA/connector-spscommerce,github +repo_oca_connector_telephony,odoo_repository_org_oca,connector-telephony,https://github.com/OCA/connector-telephony,https://github.com/OCA/connector-telephony,github +repo_oca_connector_woocommerce,odoo_repository_org_oca,connector-woocommerce,https://github.com/OCA/connector-woocommerce,https://github.com/OCA/connector-woocommerce,github +repo_oca_contract,odoo_repository_org_oca,contract,https://github.com/OCA/contract,https://github.com/OCA/contract,github +repo_oca_cooperative,odoo_repository_org_oca,cooperative,https://github.com/OCA/cooperative,https://github.com/OCA/cooperative,github +repo_oca_credit_control,odoo_repository_org_oca,credit-control,https://github.com/OCA/credit-control,https://github.com/OCA/credit-control,github +repo_oca_crm,odoo_repository_org_oca,crm,https://github.com/OCA/crm,https://github.com/OCA/crm,github +repo_oca_currency,odoo_repository_org_oca,currency,https://github.com/OCA/currency,https://github.com/OCA/currency,github +repo_oca_data_protection,odoo_repository_org_oca,data-protection,https://github.com/OCA/data-protection,https://github.com/OCA/data-protection,github +repo_oca_ddmrp,odoo_repository_org_oca,ddmrp,https://github.com/OCA/ddmrp,https://github.com/OCA/ddmrp,github +repo_oca_delivery_carrier,odoo_repository_org_oca,delivery-carrier,https://github.com/OCA/delivery-carrier,https://github.com/OCA/delivery-carrier,github +repo_oca_department,odoo_repository_org_oca,department,https://github.com/OCA/department,https://github.com/OCA/department,github +repo_oca_dms,odoo_repository_org_oca,dms,https://github.com/OCA/dms,https://github.com/OCA/dms,github +repo_oca_donation,odoo_repository_org_oca,donation,https://github.com/OCA/donation,https://github.com/OCA/donation,github +repo_oca_e_commerce,odoo_repository_org_oca,e-commerce,https://github.com/OCA/e-commerce,https://github.com/OCA/e-commerce,github +repo_oca_edi,odoo_repository_org_oca,edi,https://github.com/OCA/edi,https://github.com/OCA/edi,github +repo_oca_edi_framework,odoo_repository_org_oca,edi-framework,https://github.com/OCA/edi-framework,https://github.com/OCA/edi-framework,github +repo_oca_event,odoo_repository_org_oca,event,https://github.com/OCA/event,https://github.com/OCA/event,github +repo_oca_field_service,odoo_repository_org_oca,field-service,https://github.com/OCA/field-service,https://github.com/OCA/field-service,github +repo_oca_fleet,odoo_repository_org_oca,fleet,https://github.com/OCA/fleet,https://github.com/OCA/fleet,github +repo_oca_geospatial,odoo_repository_org_oca,geospatial,https://github.com/OCA/geospatial,https://github.com/OCA/geospatial,github +repo_oca_helpdesk,odoo_repository_org_oca,helpdesk,https://github.com/OCA/helpdesk,https://github.com/OCA/helpdesk,github +repo_oca_hr,odoo_repository_org_oca,hr,https://github.com/OCA/hr,https://github.com/OCA/hr,github +repo_oca_hr_attendance,odoo_repository_org_oca,hr-attendance,https://github.com/OCA/hr-attendance,https://github.com/OCA/hr-attendance,github +repo_oca_hr_expense,odoo_repository_org_oca,hr-expense,https://github.com/OCA/hr-expense,https://github.com/OCA/hr-expense,github +repo_oca_hr_holidays,odoo_repository_org_oca,hr-holidays,https://github.com/OCA/hr-holidays,https://github.com/OCA/hr-holidays,github +repo_oca_infrastructure,odoo_repository_org_oca,infrastructure,https://github.com/OCA/infrastructure,https://github.com/OCA/infrastructure,github +repo_oca_interface_github,odoo_repository_org_oca,interface-github,https://github.com/OCA/interface-github,https://github.com/OCA/interface-github,github +repo_oca_intrastat_extrastat,odoo_repository_org_oca,intrastat-extrastat,https://github.com/OCA/intrastat-extrastat,https://github.com/OCA/intrastat-extrastat,github +repo_oca_iot,odoo_repository_org_oca,iot,https://github.com/OCA/iot,https://github.com/OCA/iot,github +repo_oca_knowledge,odoo_repository_org_oca,knowledge,https://github.com/OCA/knowledge,https://github.com/OCA/knowledge,github +repo_oca_l10n_argentina,odoo_repository_org_oca,l10n-argentina,https://github.com/OCA/l10n-argentina,https://github.com/OCA/l10n-argentina,github +repo_oca_l10n_austria,odoo_repository_org_oca,l10n-austria,https://github.com/OCA/l10n-austria,https://github.com/OCA/l10n-austria,github +repo_oca_l10n_belarus,odoo_repository_org_oca,l10n-belarus,https://github.com/OCA/l10n-belarus,https://github.com/OCA/l10n-belarus,github +repo_oca_l10n_belgium,odoo_repository_org_oca,l10n-belgium,https://github.com/OCA/l10n-belgium,https://github.com/OCA/l10n-belgium,github +repo_oca_l10n_brazil,odoo_repository_org_oca,l10n-brazil,https://github.com/OCA/l10n-brazil,https://github.com/OCA/l10n-brazil,github +repo_oca_l10n_cambodia,odoo_repository_org_oca,l10n-cambodia,https://github.com/OCA/l10n-cambodia,https://github.com/OCA/l10n-cambodia,github +repo_oca_l10n_canada,odoo_repository_org_oca,l10n-canada,https://github.com/OCA/l10n-canada,https://github.com/OCA/l10n-canada,github +repo_oca_l10n_chile,odoo_repository_org_oca,l10n-chile,https://github.com/OCA/l10n-chile,https://github.com/OCA/l10n-chile,github +repo_oca_l10n_china,odoo_repository_org_oca,l10n-china,https://github.com/OCA/l10n-china,https://github.com/OCA/l10n-china,github +repo_oca_l10n_colombia,odoo_repository_org_oca,l10n-colombia,https://github.com/OCA/l10n-colombia,https://github.com/OCA/l10n-colombia,github +repo_oca_l10n_costa_rica,odoo_repository_org_oca,l10n-costa-rica,https://github.com/OCA/l10n-costa-rica,https://github.com/OCA/l10n-costa-rica,github +repo_oca_l10n_croatia,odoo_repository_org_oca,l10n-croatia,https://github.com/OCA/l10n-croatia,https://github.com/OCA/l10n-croatia,github +repo_oca_l10n_ecuador,odoo_repository_org_oca,l10n-ecuador,https://github.com/OCA/l10n-ecuador,https://github.com/OCA/l10n-ecuador,github +repo_oca_l10n_estonia,odoo_repository_org_oca,l10n-estonia,https://github.com/OCA/l10n-estonia,https://github.com/OCA/l10n-estonia,github +repo_oca_l10n_ethiopia,odoo_repository_org_oca,l10n-ethiopia,https://github.com/OCA/l10n-ethiopia,https://github.com/OCA/l10n-ethiopia,github +repo_oca_l10n_finland,odoo_repository_org_oca,l10n-finland,https://github.com/OCA/l10n-finland,https://github.com/OCA/l10n-finland,github +repo_oca_l10n_france,odoo_repository_org_oca,l10n-france,https://github.com/OCA/l10n-france,https://github.com/OCA/l10n-france,github +repo_oca_l10n_germany,odoo_repository_org_oca,l10n-germany,https://github.com/OCA/l10n-germany,https://github.com/OCA/l10n-germany,github +repo_oca_l10n_greece,odoo_repository_org_oca,l10n-greece,https://github.com/OCA/l10n-greece,https://github.com/OCA/l10n-greece,github +repo_oca_l10n_india,odoo_repository_org_oca,l10n-india,https://github.com/OCA/l10n-india,https://github.com/OCA/l10n-india,github +repo_oca_l10n_indonesia,odoo_repository_org_oca,l10n-indonesia,https://github.com/OCA/l10n-indonesia,https://github.com/OCA/l10n-indonesia,github +repo_oca_l10n_iran,odoo_repository_org_oca,l10n-iran,https://github.com/OCA/l10n-iran,https://github.com/OCA/l10n-iran,github +repo_oca_l10n_ireland,odoo_repository_org_oca,l10n-ireland,https://github.com/OCA/l10n-ireland,https://github.com/OCA/l10n-ireland,github +repo_oca_l10n_italy,odoo_repository_org_oca,l10n-italy,https://github.com/OCA/l10n-italy,https://github.com/OCA/l10n-italy,github +repo_oca_l10n_japan,odoo_repository_org_oca,l10n-japan,https://github.com/OCA/l10n-japan,https://github.com/OCA/l10n-japan,github +repo_oca_l10n_luxemburg,odoo_repository_org_oca,l10n-luxemburg,https://github.com/OCA/l10n-luxemburg,https://github.com/OCA/l10n-luxemburg,github +repo_oca_l10n_macedonia,odoo_repository_org_oca,l10n-macedonia,https://github.com/OCA/l10n-macedonia,https://github.com/OCA/l10n-macedonia,github +repo_oca_l10n_mexico,odoo_repository_org_oca,l10n-mexico,https://github.com/OCA/l10n-mexico,https://github.com/OCA/l10n-mexico,github +repo_oca_l10n_morocco,odoo_repository_org_oca,l10n-morocco,https://github.com/OCA/l10n-morocco,https://github.com/OCA/l10n-morocco,github +repo_oca_l10n_netherlands,odoo_repository_org_oca,l10n-netherlands,https://github.com/OCA/l10n-netherlands,https://github.com/OCA/l10n-netherlands,github +repo_oca_l10n_norway,odoo_repository_org_oca,l10n-norway,https://github.com/OCA/l10n-norway,https://github.com/OCA/l10n-norway,github +repo_oca_l10n_peru,odoo_repository_org_oca,l10n-peru,https://github.com/OCA/l10n-peru,https://github.com/OCA/l10n-peru,github +repo_oca_l10n_poland,odoo_repository_org_oca,l10n-poland,https://github.com/OCA/l10n-poland,https://github.com/OCA/l10n-poland,github +repo_oca_l10n_portugal,odoo_repository_org_oca,l10n-portugal,https://github.com/OCA/l10n-portugal,https://github.com/OCA/l10n-portugal,github +repo_oca_l10n_romania,odoo_repository_org_oca,l10n-romania,https://github.com/OCA/l10n-romania,https://github.com/OCA/l10n-romania,github +repo_oca_l10n_russia,odoo_repository_org_oca,l10n-russia,https://github.com/OCA/l10n-russia,https://github.com/OCA/l10n-russia,github +repo_oca_l10n_slovenia,odoo_repository_org_oca,l10n-slovenia,https://github.com/OCA/l10n-slovenia,https://github.com/OCA/l10n-slovenia,github +repo_oca_l10n_spain,odoo_repository_org_oca,l10n-spain,https://github.com/OCA/l10n-spain,https://github.com/OCA/l10n-spain,github +repo_oca_l10n_switzerland,odoo_repository_org_oca,l10n-switzerland,https://github.com/OCA/l10n-switzerland,https://github.com/OCA/l10n-switzerland,github +repo_oca_l10n_taiwan,odoo_repository_org_oca,l10n-taiwan,https://github.com/OCA/l10n-taiwan,https://github.com/OCA/l10n-taiwan,github +repo_oca_l10n_thailand,odoo_repository_org_oca,l10n-thailand,https://github.com/OCA/l10n-thailand,https://github.com/OCA/l10n-thailand,github +repo_oca_l10n_turkey,odoo_repository_org_oca,l10n-turkey,https://github.com/OCA/l10n-turkey,https://github.com/OCA/l10n-turkey,github +repo_oca_l10n_ukraine,odoo_repository_org_oca,l10n-ukraine,https://github.com/OCA/l10n-ukraine,https://github.com/OCA/l10n-ukraine,github +repo_oca_l10n_united_kingdom,odoo_repository_org_oca,l10n-united-kingdom,https://github.com/OCA/l10n-united-kingdom,https://github.com/OCA/l10n-united-kingdom,github +repo_oca_l10n_uruguay,odoo_repository_org_oca,l10n-uruguay,https://github.com/OCA/l10n-uruguay,https://github.com/OCA/l10n-uruguay,github +repo_oca_l10n_usa,odoo_repository_org_oca,l10n-usa,https://github.com/OCA/l10n-usa,https://github.com/OCA/l10n-usa,github +repo_oca_l10n_venezuela,odoo_repository_org_oca,l10n-venezuela,https://github.com/OCA/l10n-venezuela,https://github.com/OCA/l10n-venezuela,github +repo_oca_l10n_vietnam,odoo_repository_org_oca,l10n-vietnam,https://github.com/OCA/l10n-vietnam,https://github.com/OCA/l10n-vietnam,github +repo_oca_maintenance,odoo_repository_org_oca,maintenance,https://github.com/OCA/maintenance,https://github.com/OCA/maintenance,github +repo_oca_management_system,odoo_repository_org_oca,management-system,https://github.com/OCA/management-system,https://github.com/OCA/management-system,github +repo_oca_manufacture,odoo_repository_org_oca,manufacture,https://github.com/OCA/manufacture,https://github.com/OCA/manufacture,github +repo_oca_manufacture_reporting,odoo_repository_org_oca,manufacture-reporting,https://github.com/OCA/manufacture-reporting,https://github.com/OCA/manufacture-reporting,github +repo_oca_margin_analysis,odoo_repository_org_oca,margin-analysis,https://github.com/OCA/margin-analysis,https://github.com/OCA/margin-analysis,github +repo_oca_mis_builder,odoo_repository_org_oca,mis-builder,https://github.com/OCA/mis-builder,https://github.com/OCA/mis-builder,github +repo_oca_mis_builder_contrib,odoo_repository_org_oca,mis-builder-contrib,https://github.com/OCA/mis-builder-contrib,https://github.com/OCA/mis-builder-contrib,github +repo_oca_multi_company,odoo_repository_org_oca,multi-company,https://github.com/OCA/multi-company,https://github.com/OCA/multi-company,github +repo_oca_oca_custom,odoo_repository_org_oca,oca-custom,https://github.com/OCA/oca-custom,https://github.com/OCA/oca-custom,github +repo_oca_odoo_pim,odoo_repository_org_oca,odoo-pim,https://github.com/OCA/odoo-pim,https://github.com/OCA/odoo-pim,github +repo_oca_operating_unit,odoo_repository_org_oca,operating-unit,https://github.com/OCA/operating-unit,https://github.com/OCA/operating-unit,github +repo_oca_partner_contact,odoo_repository_org_oca,partner-contact,https://github.com/OCA/partner-contact,https://github.com/OCA/partner-contact,github +repo_oca_payroll,odoo_repository_org_oca,payroll,https://github.com/OCA/payroll,https://github.com/OCA/payroll,github +repo_oca_pms,odoo_repository_org_oca,pms,https://github.com/OCA/pms,https://github.com/OCA/pms,github +repo_oca_pos,odoo_repository_org_oca,pos,https://github.com/OCA/pos,https://github.com/OCA/pos,github +repo_oca_product_attribute,odoo_repository_org_oca,product-attribute,https://github.com/OCA/product-attribute,https://github.com/OCA/product-attribute,github +repo_oca_product_configurator,odoo_repository_org_oca,product-configurator,https://github.com/OCA/product-configurator,https://github.com/OCA/product-configurator,github +repo_oca_product_kitting,odoo_repository_org_oca,product-kitting,https://github.com/OCA/product-kitting,https://github.com/OCA/product-kitting,github +repo_oca_product_pack,odoo_repository_org_oca,product-pack,https://github.com/OCA/product-pack,https://github.com/OCA/product-pack,github +repo_oca_product_variant,odoo_repository_org_oca,product-variant,https://github.com/OCA/product-variant,https://github.com/OCA/product-variant,github +repo_oca_program,odoo_repository_org_oca,program,https://github.com/OCA/program,https://github.com/OCA/program,github +repo_oca_project,odoo_repository_org_oca,project,https://github.com/OCA/project,https://github.com/OCA/project,github +repo_oca_project_agile,odoo_repository_org_oca,project-agile,https://github.com/OCA/project-agile,https://github.com/OCA/project-agile,github +repo_oca_project_reporting,odoo_repository_org_oca,project-reporting,https://github.com/OCA/project-reporting,https://github.com/OCA/project-reporting,github +repo_oca_purchase_reporting,odoo_repository_org_oca,purchase-reporting,https://github.com/OCA/purchase-reporting,https://github.com/OCA/purchase-reporting,github +repo_oca_purchase_workflow,odoo_repository_org_oca,purchase-workflow,https://github.com/OCA/purchase-workflow,https://github.com/OCA/purchase-workflow,github +repo_oca_pwa_builder,odoo_repository_org_oca,pwa-builder,https://github.com/OCA/pwa-builder,https://github.com/OCA/pwa-builder,github +repo_oca_queue,odoo_repository_org_oca,queue,https://github.com/OCA/queue,https://github.com/OCA/queue,github +repo_oca_repair,odoo_repository_org_oca,repair,https://github.com/OCA/repair,https://github.com/OCA/repair,github +repo_oca_report_print_send,odoo_repository_org_oca,report-print-send,https://github.com/OCA/report-print-send,https://github.com/OCA/report-print-send,github +repo_oca_reporting_engine,odoo_repository_org_oca,reporting-engine,https://github.com/OCA/reporting-engine,https://github.com/OCA/reporting-engine,github +repo_oca_rest_api,odoo_repository_org_oca,rest-api,https://github.com/OCA/rest-api,https://github.com/OCA/rest-api,github +repo_oca_rest_framework,odoo_repository_org_oca,rest-framework,https://github.com/OCA/rest-framework,https://github.com/OCA/rest-framework,github +repo_oca_rma,odoo_repository_org_oca,rma,https://github.com/OCA/rma,https://github.com/OCA/rma,github +repo_oca_role_policy,odoo_repository_org_oca,role-policy,https://github.com/OCA/role-policy,https://github.com/OCA/role-policy,github +repo_oca_sale_channel,odoo_repository_org_oca,sale-channel,https://github.com/OCA/sale-channel,https://github.com/OCA/sale-channel,github +repo_oca_sale_financial,odoo_repository_org_oca,sale-financial,https://github.com/OCA/sale-financial,https://github.com/OCA/sale-financial,github +repo_oca_sale_promotion,odoo_repository_org_oca,sale-promotion,https://github.com/OCA/sale-promotion,https://github.com/OCA/sale-promotion,github +repo_oca_sale_reporting,odoo_repository_org_oca,sale-reporting,https://github.com/OCA/sale-reporting,https://github.com/OCA/sale-reporting,github +repo_oca_sale_workflow,odoo_repository_org_oca,sale-workflow,https://github.com/OCA/sale-workflow,https://github.com/OCA/sale-workflow,github +repo_oca_search_engine,odoo_repository_org_oca,search-engine,https://github.com/OCA/search-engine,https://github.com/OCA/search-engine,github +repo_oca_server_auth,odoo_repository_org_oca,server-auth,https://github.com/OCA/server-auth,https://github.com/OCA/server-auth,github +repo_oca_server_backend,odoo_repository_org_oca,server-backend,https://github.com/OCA/server-backend,https://github.com/OCA/server-backend,github +repo_oca_server_brand,odoo_repository_org_oca,server-brand,https://github.com/OCA/server-brand,https://github.com/OCA/server-brand,github +repo_oca_server_env,odoo_repository_org_oca,server-env,https://github.com/OCA/server-env,https://github.com/OCA/server-env,github +repo_oca_server_tools,odoo_repository_org_oca,server-tools,https://github.com/OCA/server-tools,https://github.com/OCA/server-tools,github +repo_oca_server_ux,odoo_repository_org_oca,server-ux,https://github.com/OCA/server-ux,https://github.com/OCA/server-ux,github +repo_oca_shift_planning,odoo_repository_org_oca,shift-planning,https://github.com/OCA/shift-planning,https://github.com/OCA/shift-planning,github +repo_oca_sign,odoo_repository_org_oca,sign,https://github.com/OCA/sign,https://github.com/OCA/sign,github +repo_oca_social,odoo_repository_org_oca,social,https://github.com/OCA/social,https://github.com/OCA/social,github +repo_oca_spreadsheet,odoo_repository_org_oca,spreadsheet,https://github.com/OCA/spreadsheet,https://github.com/OCA/spreadsheet,github +repo_oca_stock_logistics_availability,odoo_repository_org_oca,stock-logistics-availability,https://github.com/OCA/stock-logistics-availability,https://github.com/OCA/stock-logistics-availability,github +repo_oca_stock_logistics_barcode,odoo_repository_org_oca,stock-logistics-barcode,https://github.com/OCA/stock-logistics-barcode,https://github.com/OCA/stock-logistics-barcode,github +repo_oca_stock_logistics_interfaces,odoo_repository_org_oca,stock-logistics-interfaces,https://github.com/OCA/stock-logistics-interfaces,https://github.com/OCA/stock-logistics-interfaces,github +repo_oca_stock_logistics_orderpoint,odoo_repository_org_oca,stock-logistics-orderpoint,https://github.com/OCA/stock-logistics-orderpoint,https://github.com/OCA/stock-logistics-orderpoint,github +repo_oca_stock_logistics_reporting,odoo_repository_org_oca,stock-logistics-reporting,https://github.com/OCA/stock-logistics-reporting,https://github.com/OCA/stock-logistics-reporting,github +repo_oca_stock_logistics_request,odoo_repository_org_oca,stock-logistics-request,https://github.com/OCA/stock-logistics-request,https://github.com/OCA/stock-logistics-request,github +repo_oca_stock_logistics_tracking,odoo_repository_org_oca,stock-logistics-tracking,https://github.com/OCA/stock-logistics-tracking,https://github.com/OCA/stock-logistics-tracking,github +repo_oca_stock_logistics_transport,odoo_repository_org_oca,stock-logistics-transport,https://github.com/OCA/stock-logistics-transport,https://github.com/OCA/stock-logistics-transport,github +repo_oca_stock_logistics_warehouse,odoo_repository_org_oca,stock-logistics-warehouse,https://github.com/OCA/stock-logistics-warehouse,https://github.com/OCA/stock-logistics-warehouse,github +repo_oca_stock_logistics_workflow,odoo_repository_org_oca,stock-logistics-workflow,https://github.com/OCA/stock-logistics-workflow,https://github.com/OCA/stock-logistics-workflow,github +repo_oca_storage,odoo_repository_org_oca,storage,https://github.com/OCA/storage,https://github.com/OCA/storage,github +repo_oca_survey,odoo_repository_org_oca,survey,https://github.com/OCA/survey,https://github.com/OCA/survey,github +repo_oca_timesheet,odoo_repository_org_oca,timesheet,https://github.com/OCA/timesheet,https://github.com/OCA/timesheet,github +repo_oca_vertical_abbey,odoo_repository_org_oca,vertical-abbey,https://github.com/OCA/vertical-abbey,https://github.com/OCA/vertical-abbey,github +repo_oca_vertical_agriculture,odoo_repository_org_oca,vertical-agriculture,https://github.com/OCA/vertical-agriculture,https://github.com/OCA/vertical-agriculture,github +repo_oca_vertical_association,odoo_repository_org_oca,vertical-association,https://github.com/OCA/vertical-association,https://github.com/OCA/vertical-association,github +repo_oca_vertical_community,odoo_repository_org_oca,vertical-community,https://github.com/OCA/vertical-community,https://github.com/OCA/vertical-community,github +repo_oca_vertical_construction,odoo_repository_org_oca,vertical-construction,https://github.com/OCA/vertical-construction,https://github.com/OCA/vertical-construction,github +repo_oca_vertical_cooperative_supermarket,odoo_repository_org_oca,vertical-cooperative-supermarket,https://github.com/OCA/vertical-cooperative-supermarket,https://github.com/OCA/vertical-cooperative-supermarket,github +repo_oca_vertical_edition,odoo_repository_org_oca,vertical-edition,https://github.com/OCA/vertical-edition,https://github.com/OCA/vertical-edition,github +repo_oca_vertical_education,odoo_repository_org_oca,vertical-education,https://github.com/OCA/vertical-education,https://github.com/OCA/vertical-education,github +repo_oca_vertical_hotel,odoo_repository_org_oca,vertical-hotel,https://github.com/OCA/vertical-hotel,https://github.com/OCA/vertical-hotel,github +repo_oca_vertical_isp,odoo_repository_org_oca,vertical-isp,https://github.com/OCA/vertical-isp,https://github.com/OCA/vertical-isp,github +repo_oca_vertical_medical,odoo_repository_org_oca,vertical-medical,https://github.com/OCA/vertical-medical,https://github.com/OCA/vertical-medical,github +repo_oca_vertical_ngo,odoo_repository_org_oca,vertical-ngo,https://github.com/OCA/vertical-ngo,https://github.com/OCA/vertical-ngo,github +repo_oca_vertical_realestate,odoo_repository_org_oca,vertical-realestate,https://github.com/OCA/vertical-realestate,https://github.com/OCA/vertical-realestate,github +repo_oca_vertical_rental,odoo_repository_org_oca,vertical-rental,https://github.com/OCA/vertical-rental,https://github.com/OCA/vertical-rental,github +repo_oca_vertical_travel,odoo_repository_org_oca,vertical-travel,https://github.com/OCA/vertical-travel,https://github.com/OCA/vertical-travel,github +repo_oca_wallet,odoo_repository_org_oca,wallet,https://github.com/OCA/wallet,https://github.com/OCA/wallet,github +repo_oca_web,odoo_repository_org_oca,web,https://github.com/OCA/web,https://github.com/OCA/web,github +repo_oca_web_api,odoo_repository_org_oca,web-api,https://github.com/OCA/web-api,https://github.com/OCA/web-api,github +repo_oca_webhook,odoo_repository_org_oca,webhook,https://github.com/OCA/webhook,https://github.com/OCA/webhook,github +repo_oca_webkit_tools,odoo_repository_org_oca,webkit-tools,https://github.com/OCA/webkit-tools,https://github.com/OCA/webkit-tools,github +repo_oca_website,odoo_repository_org_oca,website,https://github.com/OCA/website,https://github.com/OCA/website,github +repo_oca_website_cms,odoo_repository_org_oca,website-cms,https://github.com/OCA/website-cms,https://github.com/OCA/website-cms,github +repo_oca_website_themes,odoo_repository_org_oca,website-themes,https://github.com/OCA/website-themes,https://github.com/OCA/website-themes,github +repo_oca_wms,odoo_repository_org_oca,wms,https://github.com/OCA/wms,https://github.com/OCA/wms,github diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml new file mode 100644 index 00000000..7d107d6d --- /dev/null +++ b/odoo_repository/data/odoo_repository.xml @@ -0,0 +1,20 @@ + + + + + + + odoo + https://github.com/odoo/odoo + https://github.com/odoo/odoo + github + + + + diff --git a/odoo_repository/data/odoo_repository_addons_path.xml b/odoo_repository/data/odoo_repository_addons_path.xml new file mode 100644 index 00000000..dbbbce95 --- /dev/null +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -0,0 +1,27 @@ + + + + + + ./odoo/addons + + + + + ./addons + + + + + . + + + + + + . + + + + diff --git a/odoo_repository/data/odoo_repository_org.xml b/odoo_repository/data/odoo_repository_org.xml new file mode 100644 index 00000000..4b6202d5 --- /dev/null +++ b/odoo_repository/data/odoo_repository_org.xml @@ -0,0 +1,14 @@ + + + + + + odoo + + + + OCA + + + diff --git a/odoo_repository/data/queue_job.xml b/odoo_repository/data/queue_job.xml new file mode 100644 index 00000000..07208b9c --- /dev/null +++ b/odoo_repository/data/queue_job.xml @@ -0,0 +1,30 @@ + + + + + + odoo_repository_scan + + + + + + _scan_branch + + + + + + odoo_repository_find_pr_url + + + + + + action_find_pr_url + + + + + diff --git a/odoo_repository/lib/__init__.py b/odoo_repository/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py new file mode 100644 index 00000000..bf678b7d --- /dev/null +++ b/odoo_repository/lib/scanner.py @@ -0,0 +1,596 @@ +# Copyright 2023 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import contextlib +import json +import logging +import pathlib +import os +import subprocess +import tempfile +import time + +import git +import oca_port +from odoo_addons_analyzer import ModuleAnalysis + + +# Disable logging from 'pygount' (used by odoo_addons_analyzer) +logging.getLogger("pygount").setLevel(logging.ERROR) + +_logger = logging.getLogger(__name__) + +# TODO handle Git clone/fetch through SSH: +# https://gitpython.readthedocs.io/en/stable/tutorial.html#handling-remotes +# ssh_cmd = 'ssh -i id_deployment_key' +# with repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd): +# repo.remotes.origin.fetch() + + +class BaseScanner: + _dirname = "odoo-repositories" + + def __init__( + self, + org: str, + name: str, + clone_url: str, + branches: list, + repositories_path: str = None, + ssh_key: str = None, + ): + self.org = org + self.name = name + self.clone_url = clone_url + self.branches = branches + self.repositories_path = self._prepare_repositories_path( + repositories_path + ) + self.path = self.repositories_path.joinpath(self.org, self.name) + self._apply_git_config() + self.ssh_key = ssh_key + + def scan(self): + # Clone or update the repository + if not self.is_cloned: + self._clone() + self._fetch() + + @contextlib.contextmanager + def _get_git_env(self): + """Context manager yielding env variables used by Git invocations.""" + git_env = {} + if self.ssh_key: + with self._get_ssh_key() as ssh_key_path: + ssh_key_path = "/home/salix/.ssh/testing" # FIXME test + git_ssh_cmd = f"ssh -o StrictHostKeyChecking=no -i {ssh_key_path}" + git_env.update(GIT_SSH_COMMAND=git_ssh_cmd, GIT_TRACE="true") + yield git_env + else: + yield git_env + + @contextlib.contextmanager + def _get_ssh_key(self): + """Save the SSH key in a temporary file and yield its path.""" + with tempfile.NamedTemporaryFile() as fp: + fp.write(self.ssh_key.encode()) + fp.flush() + ssh_key_path = fp.name + yield ssh_key_path + + def _prepare_repositories_path(self, repositories_path=None): + if not repositories_path: + default_data_dir_path = ( + pathlib.Path.home().joinpath(".local").joinpath("share") + ) + repositories_path = pathlib.Path( + os.environ.get("XDG_DATA_HOME", default_data_dir_path), + self._dirname, + ) + repositories_path = pathlib.Path(repositories_path) + repositories_path.mkdir(parents=True, exist_ok=True) + return repositories_path + + def _apply_git_config(self): + # This avoids too high memory consumption (default git config could + # crash the Odoo workers when the scanner is run by Odoo itself). + subprocess.run( + ["git", "config", "--global", "core.packedGitLimit", "256m"] + ) + # self.repo.config_writer().set_value( + # "core", "packedGitLimit", "256m").release() + + @property + def is_cloned(self): + return self.path.joinpath(".git").exists() + + @property + def repo(self): + return git.Repo(self.path) + + @property + def full_name(self): + return f"{self.org}/{self.name}" + + def _clone(self): + _logger.info("Cloning %s...", self.full_name) + with self._get_git_env() as git_env: + git.Repo.clone_from(self.clone_url, self.path, env=git_env) + + def _fetch(self): + repo = self.repo + _logger.info( + "%s: fetch branch(es) %s", self.full_name, ", ".join(self.branches) + ) + for branch in self.branches: + # Do not block the process if the branch doesn't exist on this repo + try: + with self._get_git_env() as git_env: + with repo.git.custom_environment(**git_env): + repo.remotes.origin.fetch(branch) + except git.exc.GitCommandError as exc: + _logger.info(exc) + else: + _logger.info("%s: branch %s fetched", self.full_name, branch) + + def _branch_exists(self, branch): + repo = self.repo + refs = [r.name for r in repo.remotes.origin.refs] + branch = f"origin/{branch}" + return branch in refs + + def _checkout_branch(self, branch): + self.repo.refs[f"origin/{branch}"].checkout() + + def _get_last_fetched_commit(self, branch): + """Return the last fetched commit for the given `branch`.""" + repo = self.repo + return repo.rev_parse(f"origin/{branch}").hexsha + + def _get_module_paths(self, relative_path, branch): + """Return modules available in `branch`. + + It returns a list of tuples `[(module, last_commit), ...]`. + """ + # Clean up 'relative_path' to make it compatible with 'git.Tree' object + relative_tree_path = "/".join( + [dir_ for dir_ in relative_path.split("/") if dir_ and dir_ != "."] + ) + # No from_commit means first scan: return all available modules + branch_commit = self.repo.refs[f"origin/{branch}"].commit + addons_trees = branch_commit.tree.trees + if relative_tree_path: + addons_trees = (branch_commit.tree / relative_tree_path).trees + return [ + (tree.path, self._get_commit_of_git_tree(f"origin/{branch}", tree)) + for tree in addons_trees + if self._odoo_module(tree) + ] + + def _get_module_paths_updated( + self, + relative_path, + from_commit, + to_commit, + branch, + ): + """Return modules updated between `from_commit` and `to_commit`. + + It returns a list of tuples `[(module, last_commit), ...]`. + """ + # Clean up 'relative_path' to make it compatible with 'git.Tree' object + relative_tree_path = "/".join( + [dir_ for dir_ in relative_path.split("/") if dir_ and dir_ != "."] + ) + module_paths = set() + # Same commits: nothing has changed + if from_commit == to_commit: + return module_paths + repo = self.repo + # Get only modules updated between the two commits + from_commit = repo.commit(from_commit) + to_commit = repo.commit(to_commit) + diffs = to_commit.diff(from_commit, R=True) + for diff in diffs: + # Skip diffs that do not belong to the scanned relative path + if not diff.a_path.startswith(relative_tree_path): + continue + # Skip diffs that relates to unrelevant files + if not self._filter_file_path(diff.a_path): + continue + # Exclude files located in root folder + if "/" not in diff.a_path: + continue + # Remove the relative_path (e.g. 'addons/') from the diff path + rel_path = pathlib.Path(relative_path) + diff_path = pathlib.Path(diff.a_path) + module_path = pathlib.Path( + *diff_path.parts[:len(rel_path.parts) + 1] + ) + tree = to_commit.tree / str(module_path) + if self._odoo_module(tree): + module_paths.add( + # FIXME: should we return pathlib.Path objects? + ( + tree.path, + self._get_commit_of_git_tree(f"origin/{branch}", tree) + ) + ) + return module_paths + + def _filter_file_path(self, path): + for ext in (".po", ".pot", ".rst", ".html"): + if path.endswith(ext): + return False + return True + + def _get_commit_of_git_tree(self, ref, tree): + return tree.repo.git.log( + "--pretty=%H", "-n 1", ref, "--", tree.path + ) + + def _odoo_module(self, tree): + """Check if the `git.Tree` object is an Odoo module.""" + # NOTE: it seems we could have data only modules without '__init__.py' + # like 'odoo/addons/test_data_module/', so the Python package check + # is maybe not useful + return self._manifest_exists(tree) # and self._python_package(tree) + + def _python_package(self, tree): + """Check if the `git.Tree` object is a Python package.""" + return bool(self._get_subtree(tree, "__init__.py")) + + def _manifest_exists(self, tree): + """Check if the `git.Tree` object contains an Odoo manifest file.""" + manifest_found = False + for manifest_file in ("__manifest__.py", "__openerp__.py"): + if self._get_subtree(tree, manifest_file): + manifest_found = True + break + return manifest_found + + def _get_subtree(self, tree, path): + """Return the subtree `tree / path` if it exists, or `None`.""" + try: + return tree / path + except KeyError: + pass + + +class MigrationScanner(BaseScanner): + + def __init__( + self, + org: str, + name: str, + clone_url: str, + migration_paths: list[tuple[str]], + repositories_path: str = None, + ssh_key: str = None, + ): + branches = sorted(set(sum([tuple(mp) for mp in migration_paths], ()))) + super().__init__( + org, name, clone_url, branches, repositories_path, ssh_key + ) + self.migration_paths = migration_paths + + def scan(self): + super().scan() + repo_id = self._get_odoo_repository_id() + # Get the repository branches from Odoo as the ones we got as parameter + # could not exist in the repository + branches = self._get_odoo_repository_branches(repo_id) + for source_branch, target_branch in self.migration_paths: + if ( + self._branch_exists(source_branch) + and self._branch_exists(target_branch) + ): + self._scan_migration_path(source_branch, target_branch) + + def _scan_migration_path(self, source_branch, target_branch): + repo = self.repo + repo_source_commit = self._get_last_fetched_commit(source_branch) + repo_target_commit = self._get_last_fetched_commit(target_branch) + modules = self._get_module_paths(".", source_branch) + for module, __ in modules: + module_branch_id = self._get_odoo_module_branch_id(module, source_branch) + if not module_branch_id: + _logger.warning( + "Module '%s' for branch %s does not exist on Odoo, " + "a new scan of the repository is required. Aborted" % ( + module, source_branch + ) + ) + continue + # For each module and source/target branch: + # - get commit of 'module' relative to the last fetched commit + # - get commit of 'module' relative to the last scanned commit + module_source_tree = self._get_subtree( + repo.commit(repo_source_commit).tree, module + ) + module_target_tree = self._get_subtree( + repo.commit(repo_target_commit).tree, module + ) + module_source_commit = self._get_commit_of_git_tree( + repo_source_commit, module_source_tree + ) + module_target_commit = ( + module_target_tree and self._get_commit_of_git_tree( + repo_target_commit, module_target_tree + ) or False + ) + # Retrieve existing migration data if any and check if it is outdated + data = self._get_odoo_module_branch_migration_data( + module, source_branch, target_branch + ) + if ( + data.get("last_source_scanned_commit") != module_source_commit + or data.get("last_target_scanned_commit") != module_target_commit + ): + self._scan_module( + module, + module_branch_id, + source_branch, + target_branch, + module_source_commit, + module_target_commit, + ) + + def _scan_module( + self, + module: str, + module_branch_id: int, + source_branch: str, + target_branch: str, + source_commit: str, + target_commit: str + ): + """Collect the migration data of a module.""" + # TODO if all the diffs from 'source_commit' to 'target_commit' + # for the current module relates to unrelevant files (po, rst, html) + # skip the scan to speed up the process and push only last scanned commits. + # OCA bots and weblate could update modules in batch to change such files, + # making the scan of all repositories quite long. + data = self._run_oca_port(module, source_branch, target_branch) + data.update( + { + "module": module, + "source_branch": source_branch, + "target_branch": target_branch, + "source_commit": source_commit, + "target_commit": target_commit, + } + ) + self._push_scanned_data(module_branch_id, data) + # Mitigate "GH API rate limit exceeds" error + time.sleep(4) + return True + + def _run_oca_port(self, module, source_branch, target_branch): + _logger.info( + "%s: collect migration data for '%s' (%s -> %s)", + self.full_name, + module, + source_branch, + target_branch, + ) + # Initialize the oca-port app + params = { + "from_branch": source_branch, + "to_branch": target_branch, + "addon": module, + "from_org": self.org, + "from_remote": "origin", + "repo_path": self.path, + "repo_name": self.name, + "output": "json", + "fetch": False, + } + scan = oca_port.App(**params) + try: + json_data = scan.run() + except ValueError as exc: + _logger.warning(exc) + else: + return json.loads(json_data) + + # Hooks method to override by client class + + def _get_odoo_repository_id(self) -> int: + """Return the ID of the 'odoo.repository' record.""" + raise NotImplementedError + + def _get_odoo_repository_branches(self, repo_id) -> list[str]: + """Return the relevant branches based on 'odoo.repository.branch'.""" + raise NotImplementedError + + def _get_odoo_migration_paths(self, branches) -> list[tuple[str]]: + """Return the available migration paths corresponding to `branches`.""" + raise NotImplementedError + + def _get_odoo_module_branch_id(self, module, branch) -> int: + """Return the ID of the 'odoo.module.branch' record.""" + raise NotImplementedError + + def _get_odoo_module_branch_migration_id( + self, module, source_branch, target_branch) -> int: + """Return the ID of 'odoo.module.branch.migration' record.""" + raise NotImplementedError + + def _get_odoo_module_branch_migration_data( + self, module, source_branch, target_branch) -> dict: + """Return the 'odoo.module.branch.migration' data.""" + raise NotImplementedError + + def _push_scanned_data(self, module_branch_id, data): + """Push the scanned module data to Odoo. + + It has to use the 'odoo.module.branch.migration.push_scanned_data' + RPC endpoint. + """ + raise NotImplementedError + + +class RepositoryScanner(BaseScanner): + + def __init__( + self, + org: str, + name: str, + clone_url: str, + branches: list, + addons_paths_data: list, + repositories_path: str = None, + ssh_key: str = None, + ): + super().__init__( + org, name, clone_url, branches, repositories_path, ssh_key + ) + self.addons_paths_data = addons_paths_data + + def scan(self): + super().scan() + repo_id = self._get_odoo_repository_id() + branches_scanned = {} + for branch in self.branches: + branches_scanned[branch] = self._scan_branch(repo_id, branch) + + def _scan_branch(self, repo_id, branch): + if not self._branch_exists(branch): + return + branch_id = self._get_odoo_branch_id(repo_id, branch) + repo_branch_id = self._create_odoo_repository_branch(repo_id, branch_id) + last_fetched_commit = self._get_last_fetched_commit(branch) + last_scanned_commit = self._get_repo_last_scanned_commit(repo_branch_id) + if last_fetched_commit != last_scanned_commit: + # Checkout the source branch to: + # - get the last commit of a module working tree + # - perform module code analysis + self._checkout_branch(branch) + # Scan relevant subfolders of the repository + for addons_path_data in self.addons_paths_data: + self._scan_addons_path( + addons_path_data, branch, repo_branch_id, + last_fetched_commit, last_scanned_commit + ) + # Flag this repository/branch as scanned + self._update_last_scanned_commit(repo_branch_id, last_fetched_commit) + return True + return False + + def _scan_addons_path( + self, addons_path_data, branch, repo_branch_id, + last_fetched_commit, last_scanned_commit + ): + if not last_scanned_commit: + module_paths = sorted( + self._get_module_paths(addons_path_data["relative_path"], branch) + ) + else: + # Get module paths updated since the last scanned commit + module_paths = sorted( + self._get_module_paths_updated( + addons_path_data["relative_path"], + from_commit=last_scanned_commit, + to_commit=last_fetched_commit, + branch=branch, + ) + ) + extra_log = "" + if addons_path_data["relative_path"] != ".": + extra_log = f" in {addons_path_data['relative_path']}" + _logger.info( + "%s: %s module(s) updated on %s" + extra_log, + self.full_name, + len(module_paths), + branch, + ) + # Scan each module + for module_path, last_module_commit in module_paths: + self._scan_module( + branch, + repo_branch_id, + module_path, + last_module_commit, + addons_path_data, + ) + + def _scan_module( + self, + branch, + repo_branch_id, + module_path, + last_module_commit, + addons_path_data, + ): + module = module_path.split("/")[-1] + last_module_scanned_commit = self._get_module_last_scanned_commit( + repo_branch_id, module + ) + # Do not scan if the module didn't changed since last scan + # NOTE we also do this check at the model level so if the process + # is interrupted (time limit, not enough memory...) we could + # resume the work where it stopped by skipping already scanned + # modules. + if last_module_scanned_commit == last_module_commit: + return + _logger.info( + "%s#%s: scan '%s' ", + self.full_name, + branch, + module_path, + ) + data = self._run_code_analysis(module_path) + if data["manifest"]: + # Insert all flags 'is_standard', 'is_enterprise', etc + data.update(addons_path_data) + # Set the last fetched commit as last scanned commit + data["last_scanned_commit"] = last_module_commit + self._push_scanned_data(repo_branch_id, module, data) + + def _run_code_analysis(self, module_path): + """Perform a code analysis of `module_path`.""" + module_analysis = ModuleAnalysis(f"{self.path}/{module_path}") + return module_analysis.to_dict() + + # Hooks method to override by client class + + def _get_odoo_repository_id(self): + """Return the ID of the 'odoo.repository' record.""" + raise NotImplementedError + + def _get_odoo_branch_id(self, repo_id, branch): + """Return the ID of the relevant 'odoo.branch' record. + + If the repository is cloned from a specific branch name + (like 'master' or 'main'), return the ID of the configured + Odoo version (`odoo.branch.odoo_version_id`). + """ + raise NotImplementedError + + def _get_odoo_repository_branch_id(self, repo_id, branch_id): + """Return the ID of the 'odoo.repository.branch' record.""" + raise NotImplementedError + + def _create_odoo_repository_branch(self, repo_id, branch_id): + """Create an 'odoo.repository.branch' record and return its ID.""" + raise NotImplementedError + + def _get_repo_last_scanned_commit(self, repo_branch_id): + """Return the last scanned commit of the repository/branch.""" + raise NotImplementedError + + def _get_module_last_scanned_commit(self, repo_branch_id, module): + """Return the last scanned commit of the module.""" + raise NotImplementedError + + def _push_scanned_data(self, repo_branch_id, module, data): + """Push the scanned module data to Odoo. + + It has to use the 'odoo.module.branch.push_scanned_data' RPC endpoint. + """ + raise NotImplementedError + + def _update_last_scanned_commit(self, repo_branch_id, last_scanned_commit): + """Update the last scanned commit for the repository/branch.""" + raise NotImplementedError diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py new file mode 100644 index 00000000..efb1e5c5 --- /dev/null +++ b/odoo_repository/models/__init__.py @@ -0,0 +1,14 @@ +from . import ssh_key +from . import odoo_author +from . import odoo_branch +from . import odoo_license +from . import odoo_maintainer +from . import odoo_module +from . import odoo_module_category +from . import odoo_module_dev_status +from . import odoo_python_dependency +from . import odoo_repository_org +from . import odoo_repository_addons_path +from . import odoo_repository +from . import odoo_repository_branch +from . import odoo_module_branch diff --git a/odoo_repository/models/odoo_author.py b/odoo_repository/models/odoo_author.py new file mode 100644 index 00000000..918f94c8 --- /dev/null +++ b/odoo_repository/models/odoo_author.py @@ -0,0 +1,20 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooAuthor(models.Model): + _name = "odoo.author" + _description = "Odoo Module Author" + _order = "name" + + name = fields.Char(required=True, index=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This author already exists." + ), + ] diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py new file mode 100644 index 00000000..65d23cd3 --- /dev/null +++ b/odoo_repository/models/odoo_branch.py @@ -0,0 +1,25 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooBranch(models.Model): + _name = "odoo.branch" + _description = "Odoo Branch" + _order = "name" + + name = fields.Char(required=True, index=True) + odoo_version = fields.Boolean( + string="Odoo Version", + default=True, + ) + active = fields.Boolean(string="Active", default=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This branch already exists." + ), + ] diff --git a/odoo_repository/models/odoo_license.py b/odoo_repository/models/odoo_license.py new file mode 100644 index 00000000..fd85f8d6 --- /dev/null +++ b/odoo_repository/models/odoo_license.py @@ -0,0 +1,20 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooLicense(models.Model): + _name = "odoo.license" + _description = "Odoo License" + _order = "name" + + name = fields.Char(required=True, index=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This license already exists." + ), + ] diff --git a/odoo_repository/models/odoo_maintainer.py b/odoo_repository/models/odoo_maintainer.py new file mode 100644 index 00000000..f57d0ccc --- /dev/null +++ b/odoo_repository/models/odoo_maintainer.py @@ -0,0 +1,34 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + +from ..utils.github import GITHUB_URL + + +class OdooMaintainer(models.Model): + _name = "odoo.maintainer" + _description = "Odoo Module Maintainer" + _order = "name" + + name = fields.Char(required=True, index=True) + github_url = fields.Char(string="GitHub URL", compute="_compute_github_url") + module_branch_ids = fields.Many2many( + comodel_name="odoo.module.branch", + relation="module_branch_maintainer_rel", + column1="maintainer_id", column2="module_branch_id", + string="Maintainers", + ) + + @api.depends("name") + def _compute_github_url(self): + for rec in self: + rec.github_url = f"{GITHUB_URL}/{rec.name}" + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This maintainer already exists." + ), + ] diff --git a/odoo_repository/models/odoo_module.py b/odoo_repository/models/odoo_module.py new file mode 100644 index 00000000..f2a06bb0 --- /dev/null +++ b/odoo_repository/models/odoo_module.py @@ -0,0 +1,24 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooModule(models.Model): + _name = "odoo.module" + _description = "Odoo Module Technical Name" + + name = fields.Char(required=True, index=True, help="Technical Name") + module_branch_ids = fields.One2many( + comodel_name="odoo.module.branch", + inverse_name="module_id", + string="Modules", + ) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This module technical name already exists." + ), + ] diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py new file mode 100644 index 00000000..c11f0aca --- /dev/null +++ b/odoo_repository/models/odoo_module_branch.py @@ -0,0 +1,410 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import random +import time +from urllib.parse import urlparse + +from odoo import api, fields, models, tools + +from odoo.addons.queue_job.exception import RetryableJobError + +from ..utils import github + + +class OdooModuleBranch(models.Model): + _name = "odoo.module.branch" + _description = "Odoo Module Branch" + _order = "module_name, branch_name" + + module_id = fields.Many2one( + comodel_name="odoo.module", + ondelete="restrict", + string="Technical name", + required=True, + index=True + ) + module_name = fields.Char( + string="Module Technical Name", related="module_id.name", store=True + ) + repository_branch_id = fields.Many2one( + comodel_name="odoo.repository.branch", + ondelete="set null", + string="Repository Branch", + index=True + ) + repository_id = fields.Many2one( + related="repository_branch_id.repository_id", + store=True, + string="Repository", + ) + org_id = fields.Many2one( + related="repository_branch_id.repository_id.org_id", + store=True, + string="Organization", + ) + branch_id = fields.Many2one( + # NOTE: not a related on 'repository_branch_id' as we need to create + # module dependencies without knowing in advance what is their repo. + comodel_name="odoo.branch", + ondelete="cascade", + string="Branch", + domain=[("odoo_version", "=", True)], + required=True, + index=True, + ) + branch_name = fields.Char( + string="Branch Name", related="branch_id.name", store=True + ) + pr_url = fields.Char(string="PR URL") + is_standard = fields.Boolean( + string="Standard?", + help="Is this module part of Odoo standard?", + default=False, + ) + is_enterprise = fields.Boolean( + string="Enterprise?", + help="Is this module designed for Odoo Enterprise only?", + default=False, + ) + is_community = fields.Boolean( + string="Community?", + help="Is this module a contribution of the community?", + default=False, + ) + title = fields.Char(index=True, help="Descriptive name") + name = fields.Char( + string="Techname", + compute="_compute_name", + store=True, + index=True, + ) + summary = fields.Char(string="Summary", index=True) + category_id = fields.Many2one( + comodel_name="odoo.module.category", + ondelete="restrict", + string="Category", + index=True, + ) + author_ids = fields.Many2many( + comodel_name="odoo.author", + string="Authors", + ) + maintainer_ids = fields.Many2many( + comodel_name="odoo.maintainer", + relation="module_branch_maintainer_rel", + column1="module_branch_id", column2="maintainer_id", + string="Maintainers", + ) + dependency_ids = fields.Many2many( + comodel_name="odoo.module.branch", + relation="module_branch_dependency_rel", + column1="module_branch_id", column2="dependency_id", + string="Dependencies", + ) + reverse_dependency_ids = fields.Many2many( + comodel_name="odoo.module.branch", + relation="module_branch_dependency_rel", + column1="dependency_id", column2="module_branch_id", + string="Reverse Dependencies", + ) + license_id = fields.Many2one( + comodel_name="odoo.license", + ondelete="restrict", + string="License", + index=True, + ) + version = fields.Char( + string="Version", + ) + development_status_id = fields.Many2one( + comodel_name="odoo.module.dev.status", + ondelete="restrict", + string="Develoment Status", + index=True, + ) + external_dependencies = fields.Serialized() + python_dependency_ids = fields.Many2many( + comodel_name="odoo.python.dependency", + string="Python Dependencies", + ) + application = fields.Boolean( + string="Application", + default=False, + ) + installable = fields.Boolean( + string="Installable", + default=True, + ) + auto_install = fields.Boolean( + string="Auto-Install", + default=False, + ) + sloc_python = fields.Integer("Python", help="Python source lines of code") + sloc_xml = fields.Integer("XML", help="XML source lines of code") + sloc_js = fields.Integer("JS", help="JavaScript source lines of code") + sloc_css = fields.Integer("CSS", help="CSS source lines of code") + last_scanned_commit = fields.Char(string="Last Scanned Commit") + + _sql_constraints = [ + ( + "module_id_branch_id_uniq", + "UNIQUE (module_id, branch_id)", + "This module already exists for this branch." + ), + ] + + @api.depends("repository_branch_id.name", "module_id.name") + def _compute_name(self): + for rec in self: + rec.name = ( + f"{rec.repository_branch_id.name or '?'}" + f" - {rec.module_id.name}" + ) + + def action_find_pr_url(self): + """Find the PR on GitHub that adds this module.""" + self.ensure_one() + if self.pr_url or self.repository_branch_id: + return False + values = {"pr_url": False} + pr_urls = self._find_pr_urls_from_github( + self.branch_id, self.module_id + ) + for pr_url in pr_urls: + values["pr_url"] = pr_url + # Get the relevant repository from PR URL if not yet defined + if not self.repository_branch_id: + repository = self._find_repository_from_pr_url(pr_url) + if not repository: + continue + repository_branch = self.env["odoo.repository.branch"].search( + [ + ("repository_id", "=", repository.id), + ("branch_id", "=", self.branch_id.id), + ] + ) + if repository_branch: + values["repository_branch_id"] = repository_branch.id + break + self.sudo().write(values) + return True + + def _find_pr_urls_from_github(self, branch, module): + """Find the GitHub Pull Requests adding `module` on `branch`.""" + # Look for an open PR first, then unmerged (which includes closed ones) + for pr_state in ("open", "unmerged"): + url = ( + f'search/issues?q=is:pr+is:{pr_state}+base:{branch.name}' + f'+in:title+{module.name}' + ) + try: + # Mitigate 'API rate limit exceeded' GitHub API error + # by adding a random waiting time of 1-4s + time.sleep(random.randrange(1, 5)) + prs = github.request(url) + except RuntimeError as exc: + raise RetryableJobError( + "Error while looking for PR URL") from exc + for pr in prs.get("items", []): + yield pr["html_url"] + + def _find_repository_from_pr_url(self, pr_url): + """Return the repository corresponding to `pr_url`.""" + # Extract organization and repository name from PR url + path_parts = list( + filter(None, urlparse(pr_url).path.split('/')) + ) + org, repository = path_parts[:2] + repository_model = self.env["odoo.repository"].with_context( + active_test=False + ) + return repository_model.search( + [ + ("org_id", "=", org), + ("name", "=", repository), + ] + ) + + @api.model + @api.returns("odoo.module.branch") + def push_scanned_data(self, repo_branch_id, module, data): + """Entry point for the scanner to push its data.""" + manifest = data["manifest"] + module = self._get_module(module) + repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) + category_id = self._get_module_category_id( + manifest.get("category", "") + ) + author_ids = self._get_author_ids(manifest.get("author", "")) + maintainer_ids = self._get_maintainer_ids( + tuple(manifest.get("maintainers", [])) + ) + dev_status_id = self._get_dev_status_id( + manifest.get("development_status", "") + ) + dependency_ids = self._get_dependency_ids( + repo_branch, manifest.get("depends", []) + ) + external_dependencies = manifest.get("external_dependencies", {}) + python_dependency_ids = self._get_python_dependency_ids( + tuple(external_dependencies.get("python", [])) + ) + license_id = self._get_license_id(manifest.get("license", "")) + values = { + "repository_branch_id": repo_branch.id, + "branch_id": repo_branch.branch_id.id, + "module_id": module.id, + "title": manifest.get("name", False), + "summary": manifest.get( + "summary", manifest.get("description", False) + ), + "category_id": category_id, + "author_ids": [(6, 0, author_ids)], + "maintainer_ids": [(6, 0, maintainer_ids)], + "dependency_ids": [(6, 0, dependency_ids)], + "external_dependencies": external_dependencies, + "python_dependency_ids": [(6, 0, python_dependency_ids)], + "license_id": license_id, + "version": manifest.get("version", False), + "development_status_id": dev_status_id, + "application": manifest.get("application", False), + "installable": manifest.get("installable", True), + "auto_install": manifest.get("auto_install", False), + "is_standard": data["is_standard"], + "is_enterprise": data["is_enterprise"], + "is_community": data["is_community"], + "sloc_python": data["code"]["Python"], + "sloc_xml": data["code"]["XML"], + "sloc_js": data["code"]["JavaScript"], + "sloc_css": data["code"]["CSS"], + "last_scanned_commit": data.get("last_scanned_commit", False), + # Unset PR URL once the module is available in the repository. + "pr_url": False, + } + return self._create_or_update(repo_branch, module, values) + + def _create_or_update(self, repo_branch, module, values): + args = [ + ("branch_id", "=", repo_branch.branch_id.id), + ("module_id", "=", module.id), + ] + module_branch = self.search(args) + if module_branch: + module_branch.sudo().write(values) + else: + module_branch = self.sudo().create(values) + return module_branch + + @tools.ormcache("category_name") + def _get_module_category_id(self, category_name): + if category_name: + rec = self.env["odoo.module.category"].search( + [("name", "=", category_name)], limit=1 + ) + if not rec: + rec = self.env["odoo.module.category"].sudo().create( + {"name": category_name} + ) + return rec.id + return False + + @tools.ormcache("names") + def _get_author_ids(self, names): + if names: + # Some Odoo std modules have a list instead of a string as 'author' + if isinstance(names, str): + names = [name.strip() for name in names.split(",")] + authors = self.env["odoo.author"].search([("name", "in", names)]) + missing_author_names = set(names) - set(authors.mapped("name")) + missing_authors = self.env["odoo.author"] + if missing_author_names: + missing_authors = self.env["odoo.author"].sudo().create( + [{"name": name} for name in missing_author_names] + ) + return (authors | missing_authors).ids + return [] + + @tools.ormcache("names") + def _get_maintainer_ids(self, names): + if names: + maintainers = self.env["odoo.maintainer"].search( + [("name", "in", names)] + ) + missing_maintainer_names = ( + set(names) - set(maintainers.mapped("name")) + ) + created = self.env["odoo.maintainer"] + if missing_maintainer_names: + created = created.sudo().create( + [{"name": name} for name in missing_maintainer_names] + ) + return (maintainers | created).ids + return [] + + @tools.ormcache("name") + def _get_dev_status_id(self, name): + if name: + rec = self.env["odoo.module.dev.status"].search( + [("name", "=", name)], limit=1 + ) + if not rec: + rec = self.env["odoo.module.dev.status"].sudo().create( + {"name": name} + ) + return rec.id + return False + + def _get_dependency_ids(self, repo_branch, depends: list): + dependency_ids = [] + for depend in depends: + module = self._get_module(depend) + dependency = self.search( + [ + ("module_id", "=", module.id), + ("branch_id", "=", repo_branch.branch_id.id), + ] + ) + if not dependency: + dependency = self.sudo().create( + { + "module_id": module.id, + "branch_id": repo_branch.branch_id.id, + } + ) + dependency_ids.append(dependency.id) + return dependency_ids + + @tools.ormcache("packages") + def _get_python_dependency_ids(self, packages): + if packages: + dependencies = self.env["odoo.python.dependency"].search( + [("name", "in", packages)] + ) + missing_dependencies = ( + set(packages) - set(dependencies.mapped("name")) + ) + created = self.env["odoo.python.dependency"] + if missing_dependencies: + created = created.sudo().create( + [{"name": package} for package in missing_dependencies] + ) + return (dependencies | created).ids + return [] + + @tools.ormcache("license_name") + def _get_license_id(self, license_name): + if license_name: + license_model = self.env["odoo.license"] + rec = license_model.search([("name", "=", license_name)], limit=1) + if not rec: + rec = license_model.sudo().create({"name": license_name}) + return rec.id + return False + + @tools.ormcache("name") + def _get_module(self, name): + module = self.env["odoo.module"].search([("name", "=", name)]) + if not module: + module = self.env["odoo.module"].sudo().create({"name": name}) + return module diff --git a/odoo_repository/models/odoo_module_category.py b/odoo_repository/models/odoo_module_category.py new file mode 100644 index 00000000..b29ba17f --- /dev/null +++ b/odoo_repository/models/odoo_module_category.py @@ -0,0 +1,19 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooModuleCategory(models.Model): + _name = "odoo.module.category" + _description = "Odoo Module Category" + + name = fields.Char(required=True, index=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This module category already exists." + ), + ] diff --git a/odoo_repository/models/odoo_module_dev_status.py b/odoo_repository/models/odoo_module_dev_status.py new file mode 100644 index 00000000..8f3bfefb --- /dev/null +++ b/odoo_repository/models/odoo_module_dev_status.py @@ -0,0 +1,19 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooModuleDevStatus(models.Model): + _name = "odoo.module.dev.status" + _description = "Odoo Module Development Status" + + name = fields.Char(required=True, index=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This development_status already exists." + ), + ] diff --git a/odoo_repository/models/odoo_python_dependency.py b/odoo_repository/models/odoo_python_dependency.py new file mode 100644 index 00000000..ef97b2a0 --- /dev/null +++ b/odoo_repository/models/odoo_python_dependency.py @@ -0,0 +1,20 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooPythonDependency(models.Model): + _name = "odoo.python.dependency" + _description = "Odoo Python Dependency" + _order = "name" + + name = fields.Char(required=True, index=True) + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This Python dependency already exists." + ), + ] diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py new file mode 100644 index 00000000..93d019b1 --- /dev/null +++ b/odoo_repository/models/odoo_repository.py @@ -0,0 +1,243 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import pathlib + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.delay import chain +from odoo.addons.queue_job.job import identity_exact + +from ..utils.scanner import RepositoryScannerOdooEnv + + +class OdooRepository(models.Model): + _name = "odoo.repository" + _description = "Odoo Modules Repository" + _order = "display_name" + + _repositories_path_key = "odoo_repository.repositories_storage_path" + + display_name = fields.Char(compute="_compute_display_name", store=True) + active = fields.Boolean(default=True) + org_id = fields.Many2one( + comodel_name="odoo.repository.org", + ondelete="cascade", + string="Organization", + required=True, + index=True, + ) + name = fields.Char(required=True, index=True) + repo_url = fields.Char( + string="Web URL", + help="Web access to this repository.", + required=True, + ) + to_scan = fields.Boolean( + string="To Scan", + default=True, + help="Scan this repository to collect data.", + ) + clone_url = fields.Char( + string="Clone URL", + help="Used to clone the repository.", + ) + repo_type = fields.Selection( + selection=[ + ("github", "GitHub"), + ("gitlab", "GitLab"), + ], + string="Repo Type", + required=True, + ) + ssh_key_id = fields.Many2one( + comodel_name="ssh.key", + ondelete="restrict", + string="SSH Key", + help="SSH key used to clone/fetch this repository." + ) + clone_branch_id = fields.Many2one( + comodel_name="odoo.branch", + ondelete="restrict", + string="Branch to clone", + help="Branch to clone if different than configured ones", + domain=[("odoo_version", "=", False)], + ) + odoo_version_id = fields.Many2one( + comodel_name="odoo.branch", + ondelete="restrict", + string="Odoo Version", + domain=[("odoo_version", "=", True)], + ) + active = fields.Boolean(string="Active", default=True) + addons_path_ids = fields.Many2many( + comodel_name="odoo.repository.addons_path", + string="Addons Path", + help="Relative path of folders in this repository hosting Odoo modules" + ) + branch_ids = fields.One2many( + comodel_name="odoo.repository.branch", + inverse_name="repository_id", + string="Branches", + readonly=True, + ) + + @api.model + def default_get(self, fields_list): + """'default_get' method overloaded.""" + res = super().default_get(fields_list) + if "addons_path_ids" not in res: + res["addons_path_ids"] = [ + ( + 4, + self.env.ref( + "odoo_repository.odoo_repository_addons_path_community" + ).id + ) + ] + return res + + @api.depends("org_id", "name") + def _compute_github_url(self): + for rec in self: + rec.github_url = f"{rec.org_id.github_url}/{rec.name}" + + _sql_constraints = [ + ( + "org_id_name_repository_id_uniq", + "UNIQUE (org_id, name, odoo_version_id)", + "This repository already exists." + ), + ] + + @api.depends("org_id.name", "name") + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{rec.org_id.name}/{rec.name}" + + @api.onchange("repo_url", "to_scan", "clone_url") + def _onchange_repo_url(self): + if not self.repo_url: + return + for type_, __ in self._fields["repo_type"].selection: + if type_ not in self.repo_url: + continue + self.repo_type = type_ + if not self.clone_url and self.to_scan: + self.clone_url = self.repo_url + break + + @api.model + def _get_odoo_branches_to_clone(self): + return self.env["odoo.branch"].search([("odoo_version", "=", True)]) + + @api.model + def cron_scanner(self, branches=None, force=False): + """Scan and collect Odoo repositories data. + + As the scanner is run on the same server than Odoo, a special class + `RepositoryScannerOdooEnv` is used so the scanner can request Odoo + through an environment (api.Environment). + """ + repositories = self.search([("to_scan", "=", True)]) + if not branches: + branches = self._get_odoo_branches_to_clone().mapped("name") + for repo in repositories: + repo.action_scan(branches=branches, force=force) + + def _check_config(self): + # Check the configuration of repositories folder + key = self._repositories_path_key + repositories_path = self.env["ir.config_parameter"].get_param(key, "") + if not repositories_path: + raise UserError( + _( + "Please define the '{key}' system parameter to " + "clone repositories in the folder of your choice.".format( + key=key + ) + ) + ) + # Ensure the folder exists + pathlib.Path(repositories_path).mkdir(parents=True, exist_ok=True) + + def action_scan(self, branches=None, force=False): + """Scan the whole repository.""" + self.ensure_one() + if not self.to_scan: + return False + self._check_config() + if self.clone_branch_id: + branches = [self.clone_branch_id.name] + if not branches: + branches = self._get_odoo_branches_to_clone().mapped("name") + if force: + self._reset_scanned_commits() + # Scan repository branches sequentially as they need to be checked out + # to perform the analysis + jobs = self._create_jobs(branches) + chain(*jobs).delay() + return True + + def _reset_scanned_commits(self): + """Reset the scanned commits. + + This will make the next repository scan restarting from the beginning, + and thus making it slower. + """ + self.ensure_one() + self.branch_ids.write({"last_scanned_commit": False}) + self.branch_ids.module_ids.sudo().write({"last_scanned_commit": False}) + + def _create_jobs(self, branches): + self.ensure_one() + jobs = [] + for branch in branches: + delayable = self.delayable( + description=f"Scan {self.display_name}#{branch}", + identity_key=identity_exact + ) + job = delayable._scan_branch(branch) + jobs.append(job) + return jobs + + def _scan_branch(self, branch): + """Scan a repository branch""" + try: + params = self._prepare_scanner_parameters(branch) + scanner = RepositoryScannerOdooEnv(**params) + return scanner.scan() + except Exception as exc: + raise RetryableJobError("Scanner error") from exc + + def _prepare_scanner_parameters(self, branch): + key = self._repositories_path_key + repositories_path = self.env["ir.config_parameter"].get_param(key) + return { + "org": self.org_id.name, + "name": self.name, + "clone_url": self.clone_url, + "branches": [branch], + "addons_paths_data": self.addons_path_ids.read( + [ + "relative_path", + "is_standard", + "is_enterprise", + "is_community", + ] + ), + "repositories_path": repositories_path, + "ssh_key": self.ssh_key_id.private_key, + "env": self.env + } + + def action_force_scan(self, branches=None): + """Force the scan of the repositories. + + It will restart the scan without considering the last scanned commit, + overriding already collected module data if any. + """ + self.ensure_one() + return self.action_scan(branches=branches, force=True) diff --git a/odoo_repository/models/odoo_repository_addons_path.py b/odoo_repository/models/odoo_repository_addons_path.py new file mode 100644 index 00000000..31260594 --- /dev/null +++ b/odoo_repository/models/odoo_repository_addons_path.py @@ -0,0 +1,40 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooRepositoryAddonsPath(models.Model): + _name = "odoo.repository.addons_path" + _description = "Addons" + + relative_path = fields.Char(required=True) + is_standard = fields.Boolean( + string="Standard?", + help="Does this folder contain modules from Odoo S.A.?", + default=False, + ) + is_enterprise = fields.Boolean( + string="Enterprise?", + help=( + "Does this folder contain Enterprise modules?\n" + "(from Odoo S.A., a contributor or your organization)" + ), + default=False, + ) + is_community = fields.Boolean( + string="Community Contribution?", + help=( + "Does this folder contain Odoo generic community modules?\n" + "(from OCA, a contributor or your organization)" + ), + default=False, + ) + + _sql_constraints = [ + ( + "addons_path_uniq", + "UNIQUE (relative_path, is_standard, is_enterprise, is_community)", + "This addons-path already exists." + ), + ] diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py new file mode 100644 index 00000000..b9bc1043 --- /dev/null +++ b/odoo_repository/models/odoo_repository_branch.py @@ -0,0 +1,63 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class OdooRepositoryBranch(models.Model): + _name = "odoo.repository.branch" + _description = "Odoo Modules Repository Branch" + + name = fields.Char(compute="_compute_name", store=True, index=True) + repository_id = fields.Many2one( + comodel_name="odoo.repository", + ondelete="cascade", + string="Repository", + required=True, + index=True, + readonly=True, + ) + branch_id = fields.Many2one( + comodel_name="odoo.branch", + ondelete="cascade", + string="Branch", + required=True, + index=True, + readonly=True, + ) + module_ids = fields.One2many( + comodel_name="odoo.module.branch", + inverse_name="repository_branch_id", + string="Modules", + readonly=True, + ) + last_scanned_commit = fields.Char(readonly=True) + + _sql_constraints = [ + ( + "repository_id_branch_id_uniq", + "UNIQUE (repository_id, branch_id)", + "This branch already exists for this repository." + ), + ] + + @api.depends("repository_id.display_name", "branch_id.name") + def _compute_name(self): + for rec in self: + rec.name = f"{rec.repository_id.display_name}#{rec.branch_id.name}" + + def action_scan(self, force=False): + """Scan the repository/branch.""" + self.ensure_one() + return self.repository_id.action_scan( + branches=[self.branch_id.name], force=force + ) + + def action_force_scan(self): + """Force the scan of the repository/branch. + + It will restart the scan without considering the last scanned commit, + overriding already collected module data if any. + """ + self.ensure_one() + return self.action_scan(force=True) diff --git a/odoo_repository/models/odoo_repository_org.py b/odoo_repository/models/odoo_repository_org.py new file mode 100644 index 00000000..f13ecb25 --- /dev/null +++ b/odoo_repository/models/odoo_repository_org.py @@ -0,0 +1,27 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + +from ..utils.github import GITHUB_URL + + +class OdooRepositoryOrg(models.Model): + _name = "odoo.repository.org" + _description = "Odoo Repository Organization" + + name = fields.Char(required=True, index=True) + github_url = fields.Char(string="GitHub URL", compute="_compute_github_url") + + @api.depends("name") + def _compute_github_url(self): + for rec in self: + rec.github_url = f"{GITHUB_URL}/{rec.name}" + + _sql_constraints = [ + ( + "name_uniq", + "UNIQUE (name)", + "This organization already exists." + ), + ] diff --git a/odoo_repository/models/ssh_key.py b/odoo_repository/models/ssh_key.py new file mode 100644 index 00000000..810fc6b1 --- /dev/null +++ b/odoo_repository/models/ssh_key.py @@ -0,0 +1,15 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class SSHKey(models.Model): + _name = "ssh.key" + _description = "SSH private key" + + name = fields.Char(required=True) + private_key = fields.Text( + required=True, + help="SSH private key without passphrase." + ) diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv new file mode 100644 index 00000000..c16e1ebb --- /dev/null +++ b/odoo_repository/security/ir.model.access.csv @@ -0,0 +1,21 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ssh_key_user,ssh_key_user,model_ssh_key,base.group_user,0,0,0,0 +access_ssh_key_manager,ssh_key_manager,model_ssh_key,base.group_system,1,1,1,1 +access_odoo_author_user,odoo_author_user,model_odoo_author,base.group_user,1,0,0,0 +access_odoo_branch_user,odoo_branch_user,model_odoo_branch,base.group_user,1,0,0,0 +access_odoo_branch_manager,odoo_branch_manager,model_odoo_branch,base.group_system,1,1,1,1 +access_odoo_license_user,odoo_license_user,model_odoo_license,base.group_user,1,0,0,0 +access_odoo_maintainer_user,odoo_maintainer_user,model_odoo_maintainer,base.group_user,1,0,0,0 +access_odoo_module_user,odoo_module_user,model_odoo_module,base.group_user,1,0,0,0 +access_odoo_module_category_user,odoo_module_category_user,model_odoo_module_category,base.group_user,1,0,0,0 +access_odoo_module_dev_status_user,odoo_module_dev_status_user,model_odoo_module_dev_status,base.group_user,1,0,0,0 +access_odoo_python_dependency_user,odoo_python_dependency_user,model_odoo_python_dependency,base.group_user,1,0,0,0 +access_odoo_repository_addons_path_user,odoo_repository_addons_path_user,model_odoo_repository_addons_path,base.group_user,1,0,0,0 +access_odoo_repository_addons_path_manager,odoo_repository_addons_path_manager,model_odoo_repository_addons_path,base.group_system,1,1,1,1 +access_odoo_repository_org_user,odoo_repository_org_user,model_odoo_repository_org,base.group_user,1,0,0,0 +access_odoo_repository_org_manager,odoo_repository_org_manager,model_odoo_repository_org,base.group_system,1,1,1,1 +access_odoo_repository_user,odoo_repository_user,model_odoo_repository,base.group_user,1,0,0,0 +access_odoo_repository_manager,odoo_repository_manager,model_odoo_repository,base.group_system,1,1,1,1 +access_odoo_repository_branch_user,odoo_repository_branch_user,model_odoo_repository_branch,base.group_user,1,0,0,0 +access_odoo_repository_branch_manager,odoo_repository_branch_manager,model_odoo_repository_branch,base.group_system,1,1,1,1 +access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch,base.group_user,1,0,0,0 diff --git a/odoo_repository/static/description/icon.png b/odoo_repository/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6d1f743407f3a20f1ac86812fc00d35c49500b4e GIT binary patch literal 26656 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 literal 0 HcmV?d00001 diff --git a/odoo_repository/utils/__init__.py b/odoo_repository/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/odoo_repository/utils/github.py b/odoo_repository/utils/github.py new file mode 100644 index 00000000..5b8d4c13 --- /dev/null +++ b/odoo_repository/utils/github.py @@ -0,0 +1,28 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os + +import requests + +GITHUB_URL = "https://github.com" +GITHUB_API_URL = "https://api.github.com" + + +def request(url, method="get", params=None, json=None): + """Request GitHub API.""" + headers = {"Accept": "application/vnd.github.groot-preview+json"} + # TODO store a GITHUB_TOKEN in Odoo config + if os.environ.get("GITHUB_TOKEN"): + token = os.environ.get("GITHUB_TOKEN") + headers.update({"Authorization": f"token {token}"}) + full_url = "/".join([GITHUB_API_URL, url]) + kwargs = {"headers": headers} + if json: + kwargs.update(json=json) + if params: + kwargs.update(params=params) + response = getattr(requests, method)(full_url, **kwargs) + if not response.ok: + raise RuntimeError(response.text) + return response.json() diff --git a/odoo_repository/utils/scanner.py b/odoo_repository/utils/scanner.py new file mode 100644 index 00000000..c7641270 --- /dev/null +++ b/odoo_repository/utils/scanner.py @@ -0,0 +1,83 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from ..lib.scanner import RepositoryScanner + + +class RepositoryScannerOdooEnv(RepositoryScanner): + """RepositoryScanner running on the same server than Odoo. + + This class takes an additional `env` parameter (`odoo.api.Environment`) + used to request Odoo, and implement required methods to use it. + """ + + def __init__(self, *args, **kwargs): + if kwargs.get("env"): + self.env = kwargs.pop("env") + super().__init__(*args, **kwargs) + + def _get_odoo_repository_id(self): + return self.env["odoo.repository"].search( + [("name", "=", self.name), ("org_id", "=", self.org)] + ).id + + def _get_odoo_branch_id(self, repo_id, branch): + repo = self.env["odoo.repository"].browse(repo_id) + if repo.clone_branch_id and repo.odoo_version_id: + return repo.odoo_version_id.id + branch = self.env["odoo.branch"].search( + [("name", "=", branch), ("odoo_version", "=", True)] + ) + return branch.id + + def _get_odoo_repository_branch_id(self, repo_id, branch_id): + args = [ + ("repository_id", "=", repo_id), + ("branch_id", "=", branch_id), + ] + repo_branch = self.env["odoo.repository.branch"].search(args, limit=1) + if repo_branch: + return repo_branch.id + + def _create_odoo_repository_branch(self, repo_id, branch_id): + repo_branch_id = self._get_odoo_repository_branch_id( + repo_id, branch_id + ) + if not repo_branch_id: + values = { + "repository_id": repo_id, + "branch_id": branch_id, + } + repo_branch_model = self.env["odoo.repository.branch"] + repo_branch_id = repo_branch_model.create(values).id + return repo_branch_id + + def _get_repo_last_scanned_commit(self, repo_branch_id): + repo_branch_model = self.env["odoo.repository.branch"] + repo_branch = repo_branch_model.browse(repo_branch_id) + return repo_branch.last_scanned_commit + + def _get_module_last_scanned_commit(self, repo_branch_id, module_name): + module_branch_model = self.env["odoo.module.branch"] + args = [ + ("repository_branch_id", "=", repo_branch_id), + ("module_name", "=", module_name), + ] + module = module_branch_model.search(args) + return module.last_scanned_commit + + def _push_scanned_data(self, repo_branch_id, module, data): + res = self.env["odoo.module.branch"].push_scanned_data( + repo_branch_id, module, data + ) + # Commit after each module + self.env.cr.commit() + return res + + def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): + repo_branch_model = self.env["odoo.repository.branch"] + repo_branch = repo_branch_model.browse(repo_branch_id) + repo_branch.last_scanned_commit = last_fetched_commit + # Commit after each repository/branch + self.env.cr.commit() + return True diff --git a/odoo_repository/views/menu.xml b/odoo_repository/views/menu.xml new file mode 100644 index 00000000..98d3d005 --- /dev/null +++ b/odoo_repository/views/menu.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/odoo_repository/views/odoo_author.xml b/odoo_repository/views/odoo_author.xml new file mode 100644 index 00000000..4610d14d --- /dev/null +++ b/odoo_repository/views/odoo_author.xml @@ -0,0 +1,52 @@ + + + + + + odoo.author.form + odoo.author + +
+ + + + + +
+
+
+ + + odoo.author.tree + odoo.author + + + + + + + + + odoo.author.search + odoo.author + search + + + + + + + + + Authors + ir.actions.act_window + odoo.author + + + + + +
diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml new file mode 100644 index 00000000..e3e17fe1 --- /dev/null +++ b/odoo_repository/views/odoo_branch.xml @@ -0,0 +1,42 @@ + + + + + + odoo.branch.tree + odoo.branch + + + + + + + + + + odoo.branch.search + odoo.branch + search + + + + + + + + + + + Branches + ir.actions.act_window + odoo.branch + + + + + + diff --git a/odoo_repository/views/odoo_license.xml b/odoo_repository/views/odoo_license.xml new file mode 100644 index 00000000..6e70ce8a --- /dev/null +++ b/odoo_repository/views/odoo_license.xml @@ -0,0 +1,38 @@ + + + + + + odoo.license.tree + odoo.license + + + + + + + + + odoo.license.search + odoo.license + search + + + + + + + + + Licenses + ir.actions.act_window + odoo.license + + + + + + diff --git a/odoo_repository/views/odoo_maintainer.xml b/odoo_repository/views/odoo_maintainer.xml new file mode 100644 index 00000000..4405c43c --- /dev/null +++ b/odoo_repository/views/odoo_maintainer.xml @@ -0,0 +1,67 @@ + + + + + + odoo.maintainer.form + odoo.maintainer + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + odoo.maintainer.tree + odoo.maintainer + + + + + + + + + + odoo.maintainer.search + odoo.maintainer + search + + + + + + + + + Maintainers + ir.actions.act_window + odoo.maintainer + + + + + +
diff --git a/odoo_repository/views/odoo_module.xml b/odoo_repository/views/odoo_module.xml new file mode 100644 index 00000000..cf45de16 --- /dev/null +++ b/odoo_repository/views/odoo_module.xml @@ -0,0 +1,66 @@ + + + + + + odoo.module.form + odoo.module + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + odoo.module.tree + odoo.module + + + + + + + + + odoo.module.search + odoo.module + search + + + + + + + + + Module Technical Names + ir.actions.act_window + odoo.module + + + + + +
diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml new file mode 100644 index 00000000..e2365dcb --- /dev/null +++ b/odoo_repository/views/odoo_module_branch.xml @@ -0,0 +1,174 @@ + + + + + + odoo.module.branch.form + odoo.module.branch + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + odoo.module.branch.tree + odoo.module.branch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + odoo.module.branch.search + odoo.module.branch + search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Modules + ir.actions.act_window + odoo.module.branch + + {'search_default_installable': 1} + + + + +
diff --git a/odoo_repository/views/odoo_module_category.xml b/odoo_repository/views/odoo_module_category.xml new file mode 100644 index 00000000..88144f8c --- /dev/null +++ b/odoo_repository/views/odoo_module_category.xml @@ -0,0 +1,38 @@ + + + + + + odoo.module.category.tree + odoo.module.category + + + + + + + + + odoo.module.category.search + odoo.module.category + search + + + + + + + + + Categories + ir.actions.act_window + odoo.module.category + + + + + + diff --git a/odoo_repository/views/odoo_module_dev_status.xml b/odoo_repository/views/odoo_module_dev_status.xml new file mode 100644 index 00000000..40247669 --- /dev/null +++ b/odoo_repository/views/odoo_module_dev_status.xml @@ -0,0 +1,38 @@ + + + + + + odoo.module.dev.status.tree + odoo.module.dev.status + + + + + + + + + odoo.module.dev.status.search + odoo.module.dev.status + search + + + + + + + + + Development Status + ir.actions.act_window + odoo.module.dev.status + + + + + + diff --git a/odoo_repository/views/odoo_python_dependency.xml b/odoo_repository/views/odoo_python_dependency.xml new file mode 100644 index 00000000..c1c8a41d --- /dev/null +++ b/odoo_repository/views/odoo_python_dependency.xml @@ -0,0 +1,38 @@ + + + + + + odoo.python.dependency.tree + odoo.python.dependency + + + + + + + + + odoo.python.dependency.search + odoo.python.dependency + search + + + + + + + + + Python Dependencies + ir.actions.act_window + odoo.python.dependency + + + + + + diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml new file mode 100644 index 00000000..8318428c --- /dev/null +++ b/odoo_repository/views/odoo_repository.xml @@ -0,0 +1,125 @@ + + + + + + odoo.repository.form + odoo.repository + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + odoo.repository.tree + odoo.repository + + + + + + + + + + + + + odoo.repository.search + odoo.repository + search + + + + + + + + + + + + + + + Repositories + ir.actions.act_window + odoo.repository + + + + + +
diff --git a/odoo_repository/views/odoo_repository_addons_path.xml b/odoo_repository/views/odoo_repository_addons_path.xml new file mode 100644 index 00000000..7e273c2e --- /dev/null +++ b/odoo_repository/views/odoo_repository_addons_path.xml @@ -0,0 +1,47 @@ + + + + + + odoo.repository.addons_path.form + odoo.repository.addons_path + +
+ + + + + + + + +
+
+
+ + + odoo.repository.addons_path.tree + odoo.repository.addons_path + + + + + + + + + + + + Addons Path + ir.actions.act_window + odoo.repository.addons_path + + + + + +
diff --git a/odoo_repository/views/odoo_repository_branch.xml b/odoo_repository/views/odoo_repository_branch.xml new file mode 100644 index 00000000..258a9f75 --- /dev/null +++ b/odoo_repository/views/odoo_repository_branch.xml @@ -0,0 +1,55 @@ + + + + + + odoo.repository.branch.form + odoo.repository.branch + +
+
+
+ + + + + + + + + + +
+
+
+ + + odoo.repository.branch.tree + odoo.repository.branch + + + + + + + + + + odoo.repository.branch.search + odoo.repository.branch + search + + + + + + + + +
diff --git a/odoo_repository/views/odoo_repository_org.xml b/odoo_repository/views/odoo_repository_org.xml new file mode 100644 index 00000000..25427fc0 --- /dev/null +++ b/odoo_repository/views/odoo_repository_org.xml @@ -0,0 +1,52 @@ + + + + + + odoo.repository.org.form + odoo.repository.org + +
+ + + + + +
+
+
+ + + odoo.repository.org.tree + odoo.repository.org + + + + + + + + + odoo.repository.org.search + odoo.repository.org + search + + + + + + + + + Repository Organizations + ir.actions.act_window + odoo.repository.org + + + + + +
diff --git a/odoo_repository/views/ssh_key.xml b/odoo_repository/views/ssh_key.xml new file mode 100644 index 00000000..37f93511 --- /dev/null +++ b/odoo_repository/views/ssh_key.xml @@ -0,0 +1,55 @@ + + + + + + ssh.key.form + ssh.key + + +
+ + + + + + +
+
+
+ + + ssh.key.tree + ssh.key + + + + + + + + + ssh.key.search + ssh.key + search + + + + + + + + + SSH Keys + ir.actions.act_window + ssh.key + + + + + + +
From b59fc62145e7691a706319b9051d24a681e4151c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 10 Oct 2023 18:39:28 +0200 Subject: [PATCH 002/160] odoo_repository: add settings panel And handle GitHub token. --- odoo_repository/__manifest__.py | 2 +- odoo_repository/data/ir_config_parameter.xml | 9 --- odoo_repository/lib/scanner.py | 9 ++- odoo_repository/models/__init__.py | 1 + odoo_repository/models/odoo_module_branch.py | 2 +- odoo_repository/models/odoo_repository.py | 12 ++- odoo_repository/models/res_config_settings.py | 15 ++++ odoo_repository/utils/github.py | 11 ++- odoo_repository/views/res_config_settings.xml | 75 +++++++++++++++++++ 9 files changed, 116 insertions(+), 20 deletions(-) delete mode 100644 odoo_repository/data/ir_config_parameter.xml create mode 100644 odoo_repository/models/res_config_settings.py create mode 100644 odoo_repository/views/res_config_settings.xml diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index f147feaa..647fecc1 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -9,7 +9,6 @@ "website": "https://github.com/OCA/TODO", "data": [ "security/ir.model.access.csv", - "data/ir_config_parameter.xml", "data/ir_cron.xml", "data/odoo_repository_org.xml", "data/odoo_repository_addons_path.xml", @@ -31,6 +30,7 @@ "views/odoo_repository_addons_path.xml", "views/odoo_repository_branch.xml", "views/odoo_repository_org.xml", + "views/res_config_settings.xml", ], "installable": True, "application": True, diff --git a/odoo_repository/data/ir_config_parameter.xml b/odoo_repository/data/ir_config_parameter.xml deleted file mode 100644 index 227feeba..00000000 --- a/odoo_repository/data/ir_config_parameter.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - odoo_repository.repositories_storage_path - - - diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index bf678b7d..90199a0c 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -38,6 +38,7 @@ def __init__( branches: list, repositories_path: str = None, ssh_key: str = None, + github_token: str = None, ): self.org = org self.name = name @@ -49,6 +50,7 @@ def __init__( self.path = self.repositories_path.joinpath(self.org, self.name) self._apply_git_config() self.ssh_key = ssh_key + self.github_token = github_token def scan(self): # Clone or update the repository @@ -267,10 +269,11 @@ def __init__( migration_paths: list[tuple[str]], repositories_path: str = None, ssh_key: str = None, + github_token: str = None, ): branches = sorted(set(sum([tuple(mp) for mp in migration_paths], ()))) super().__init__( - org, name, clone_url, branches, repositories_path, ssh_key + org, name, clone_url, branches, repositories_path, ssh_key, github_token ) self.migration_paths = migration_paths @@ -385,6 +388,7 @@ def _run_oca_port(self, module, source_branch, target_branch): "repo_name": self.name, "output": "json", "fetch": False, + "github_token": self.github_token, } scan = oca_port.App(**params) try: @@ -442,9 +446,10 @@ def __init__( addons_paths_data: list, repositories_path: str = None, ssh_key: str = None, + github_token: str = None, ): super().__init__( - org, name, clone_url, branches, repositories_path, ssh_key + org, name, clone_url, branches, repositories_path, ssh_key, github_token ) self.addons_paths_data = addons_paths_data diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py index efb1e5c5..af6a0739 100644 --- a/odoo_repository/models/__init__.py +++ b/odoo_repository/models/__init__.py @@ -12,3 +12,4 @@ from . import odoo_repository from . import odoo_repository_branch from . import odoo_module_branch +from . import res_config_settings diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index c11f0aca..b4c1e3b1 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -202,7 +202,7 @@ def _find_pr_urls_from_github(self, branch, module): # Mitigate 'API rate limit exceeded' GitHub API error # by adding a random waiting time of 1-4s time.sleep(random.randrange(1, 5)) - prs = github.request(url) + prs = github.request(self.env, url) except RuntimeError as exc: raise RetryableJobError( "Error while looking for PR URL") from exc diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 93d019b1..054f06c2 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -1,6 +1,7 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import os import pathlib from odoo import _, api, fields, models @@ -18,7 +19,7 @@ class OdooRepository(models.Model): _description = "Odoo Modules Repository" _order = "display_name" - _repositories_path_key = "odoo_repository.repositories_storage_path" + _repositories_path_key = "odoo_repository_storage_path" display_name = fields.Char(compute="_compute_display_name", store=True) active = fields.Boolean(default=True) @@ -213,8 +214,12 @@ def _scan_branch(self, branch): raise RetryableJobError("Scanner error") from exc def _prepare_scanner_parameters(self, branch): - key = self._repositories_path_key - repositories_path = self.env["ir.config_parameter"].get_param(key) + ir_config = self.env["ir.config_parameter"] + repositories_path = ir_config.get_param(self._repositories_path_key) + github_token = ir_config.get_param( + "odoo_repository_github_token", + os.environ.get("GITHUB_TOKEN") + ) return { "org": self.org_id.name, "name": self.name, @@ -230,6 +235,7 @@ def _prepare_scanner_parameters(self, branch): ), "repositories_path": repositories_path, "ssh_key": self.ssh_key_id.private_key, + "github_token": github_token, "env": self.env } diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py new file mode 100644 index 00000000..ebf64cf4 --- /dev/null +++ b/odoo_repository/models/res_config_settings.py @@ -0,0 +1,15 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + config_odoo_repository_storage_path = fields.Char( + string="Storage local path", config_parameter="odoo_repository_storage_path" + ) + config_odoo_repository_github_token = fields.Char( + string="GitHub Token", config_parameter="odoo_repository_github_token" + ) diff --git a/odoo_repository/utils/github.py b/odoo_repository/utils/github.py index 5b8d4c13..1b52e96d 100644 --- a/odoo_repository/utils/github.py +++ b/odoo_repository/utils/github.py @@ -9,12 +9,15 @@ GITHUB_API_URL = "https://api.github.com" -def request(url, method="get", params=None, json=None): +def request(env, url, method="get", params=None, json=None): """Request GitHub API.""" headers = {"Accept": "application/vnd.github.groot-preview+json"} - # TODO store a GITHUB_TOKEN in Odoo config - if os.environ.get("GITHUB_TOKEN"): - token = os.environ.get("GITHUB_TOKEN") + key = "odoo_repository_github_token" + token = ( + env["ir.config_parameter"].get_param(key, "") + or os.environ.get("GITHUB_TOKEN") + ) + if token: headers.update({"Authorization": f"token {token}"}) full_url = "/".join([GITHUB_API_URL, url]) kwargs = {"headers": headers} diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml new file mode 100644 index 00000000..7d908390 --- /dev/null +++ b/odoo_repository/views/res_config_settings.xml @@ -0,0 +1,75 @@ + + + + + + res.config.settings.form.inherit + res.config.settings + + + +
+
+

Odoo Repositories

+
+
+ Storage local path +
+ Where to clone repositories on the local filesystem. +
+
+
+ +
+
+
+
+
+
+ GitHub token (API) +
+ GitHub token used to request the API. +
+
+
+ +
+
+
+ Another way is to define the GITHUB_TOKEN environment variable on your system. +
+
+
+
+
+
+
+
+ + + Settings + ir.actions.act_window + res.config.settings + + form + inline + {'module' : 'odoo_repository', 'bin_size': False} + + + + +
From 863aacd91323289653da43bd67249357d7c06db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 2 Nov 2023 16:10:56 +0100 Subject: [PATCH 003/160] Add 'odoo_addons_analyzer' package This could be an external package published on PyPI, but include it in `odoo_repository` module for now to ease deployment. --- odoo_repository/__manifest__.py | 3 +- .../lib/odoo_addons_analyzer/__init__.py | 4 ++ .../lib/odoo_addons_analyzer/module.py | 66 +++++++++++++++++++ .../lib/odoo_addons_analyzer/repository.py | 36 ++++++++++ odoo_repository/lib/scanner.py | 2 +- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 odoo_repository/lib/odoo_addons_analyzer/__init__.py create mode 100644 odoo_repository/lib/odoo_addons_analyzer/module.py create mode 100644 odoo_repository/lib/odoo_addons_analyzer/repository.py diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 647fecc1..7f75f49e 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -43,8 +43,9 @@ "external_dependencies": { "python": [ "gitpython", - "odoo-addons-analyzer", + "pygount", # TODO to publish + # "odoo-addons-analyzer", # "odoo-repository-scanner" ], }, diff --git a/odoo_repository/lib/odoo_addons_analyzer/__init__.py b/odoo_repository/lib/odoo_addons_analyzer/__init__.py new file mode 100644 index 00000000..2feb5d61 --- /dev/null +++ b/odoo_repository/lib/odoo_addons_analyzer/__init__.py @@ -0,0 +1,4 @@ +from .module import ModuleAnalysis +from .repository import RepositoryAnalysis + +__all__ = ["ModuleAnalysis", "RepositoryAnalysis"] diff --git a/odoo_repository/lib/odoo_addons_analyzer/module.py b/odoo_repository/lib/odoo_addons_analyzer/module.py new file mode 100644 index 00000000..7a08f3bc --- /dev/null +++ b/odoo_repository/lib/odoo_addons_analyzer/module.py @@ -0,0 +1,66 @@ +# Copyright 2023 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import ast +import os +import pathlib + +import pygount + + +class ModuleAnalysis: + def __init__( + self, + folder_path, + languages=("Python", "XML", "CSS", "JavaScript"), + repo_analysis=None, + ): + self.folder_path = folder_path + self.languages = languages + self.repo_analysis = repo_analysis + self.summary = pygount.ProjectSummary() + self._run() + + @property + def name(self): + return os.path.basename(self.folder_path) + + @property + def file_paths(self): + return [ + os.path.join(dirpath, f) + for (dirpath, dirnames, filenames) in os.walk(self.folder_path) + for f in filenames + ] + + @property + def manifest(self): + for manifest_name in ("__openerp__.py", "__manifest__.py"): + manifest_path = pathlib.Path(self.folder_path, manifest_name) + if manifest_path.exists(): + with open(manifest_path) as file_: + try: + manifest = ast.literal_eval(file_.read()) + except ValueError: + return {} + return manifest + return {} + + def _run(self): + for file_path in self.file_paths: + source_analysis = pygount.SourceAnalysis.from_file( + file_path, + group=os.path.basename(self.folder_path), + encoding="utf-8", + ) + self.summary.add(source_analysis) + + def to_dict(self): + summaries = dict.fromkeys(self.languages, 0) + data = {"code": summaries, "manifest": self.manifest} + for summary in self.summary.language_to_language_summary_map.values(): + for language in self.languages: + if not summary.language.startswith(language): + continue + summaries[language] += summary.code_count + return data diff --git a/odoo_repository/lib/odoo_addons_analyzer/repository.py b/odoo_repository/lib/odoo_addons_analyzer/repository.py new file mode 100644 index 00000000..6906a63b --- /dev/null +++ b/odoo_repository/lib/odoo_addons_analyzer/repository.py @@ -0,0 +1,36 @@ +# Copyright 2023 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import glob +import os + +from .module import ModuleAnalysis + + +class RepositoryAnalysis: + def __init__( + self, + folder_path, + languages=("Python", "XML", "CSS", "JavaScript"), + name=None, + ): + self.folder_path = folder_path + self.languages = languages + self.name = os.path.basename(folder_path) if name is None else name + + @property + def modules_paths(self): + pattern1 = os.path.join(self.folder_path, "*", "__manifest__.py") + pattern2 = os.path.join(self.folder_path, "*", "__openerp__.py") + file_paths = glob.glob(pattern1) + glob.glob(pattern2) + return [os.path.dirname(file_path) for file_path in file_paths] + + def to_dict(self): + data = {} + for module_path in self.modules_paths: + module = os.path.basename(module_path) + analysis = ModuleAnalysis( + module_path, languages=self.languages, repo_analysis=self + ) + data[module] = analysis.to_dict() + return data diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 90199a0c..ca242fa1 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -12,7 +12,7 @@ import git import oca_port -from odoo_addons_analyzer import ModuleAnalysis +from .odoo_addons_analyzer import ModuleAnalysis # Disable logging from 'pygount' (used by odoo_addons_analyzer) From acf084167dcb2f05298375bbc9625b039f65547f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 3 Nov 2023 11:13:16 +0100 Subject: [PATCH 004/160] New data model 'odoo.project.module' This new data model is here to distinguish available upstream modules and installed modules in a project. It inherits from `odoo.module.branch` so it has access to all its data, but is linked to an `odoo.project` and has its own `installed_version` so it becomes easy to find modules that could be upgraded within a project. --- odoo_repository/views/odoo_module_branch.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index e2365dcb..5fddec00 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -99,6 +99,7 @@ + From 118c0bfcf8909f1c9c18c7a178379e1eea21d1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 3 Nov 2023 12:13:31 +0100 Subject: [PATCH 005/160] Apply pre-commit --- odoo_repository/README.rst | 61 +++ odoo_repository/__manifest__.py | 2 +- odoo_repository/data/ir_cron.xml | 11 +- odoo_repository/data/odoo_repository.xml | 11 +- .../data/odoo_repository_addons_path.xml | 32 +- odoo_repository/data/odoo_repository_org.xml | 2 +- odoo_repository/data/queue_job.xml | 7 +- odoo_repository/lib/scanner.py | 96 ++-- odoo_repository/models/odoo_author.py | 6 +- odoo_repository/models/odoo_branch.py | 13 +- odoo_repository/models/odoo_license.py | 6 +- odoo_repository/models/odoo_maintainer.py | 9 +- odoo_repository/models/odoo_module.py | 6 +- odoo_repository/models/odoo_module_branch.py | 95 ++-- .../models/odoo_module_category.py | 6 +- .../models/odoo_module_dev_status.py | 6 +- .../models/odoo_python_dependency.py | 6 +- odoo_repository/models/odoo_repository.py | 25 +- .../models/odoo_repository_addons_path.py | 2 +- .../models/odoo_repository_branch.py | 2 +- odoo_repository/models/odoo_repository_org.py | 6 +- odoo_repository/models/ssh_key.py | 5 +- odoo_repository/readme/CONTRIBUTORS.rst | 2 + odoo_repository/readme/DESCRIPTION.rst | 1 + odoo_repository/static/description/icon2.png | Bin 0 -> 5336 bytes odoo_repository/static/description/icon2.xcf | Bin 0 -> 13725 bytes odoo_repository/static/description/icon3.png | Bin 0 -> 28101 bytes odoo_repository/static/description/icon4.png | Bin 0 -> 28146 bytes odoo_repository/static/description/index.html | 417 ++++++++++++++++++ odoo_repository/utils/github.py | 5 +- odoo_repository/utils/scanner.py | 16 +- odoo_repository/views/menu.xml | 28 +- odoo_repository/views/odoo_author.xml | 18 +- odoo_repository/views/odoo_branch.xml | 27 +- odoo_repository/views/odoo_license.xml | 16 +- odoo_repository/views/odoo_maintainer.xml | 36 +- odoo_repository/views/odoo_module.xml | 34 +- odoo_repository/views/odoo_module_branch.xml | 253 ++++++----- .../views/odoo_module_category.xml | 16 +- .../views/odoo_module_dev_status.xml | 16 +- .../views/odoo_python_dependency.xml | 16 +- odoo_repository/views/odoo_repository.xml | 148 ++++--- .../views/odoo_repository_addons_path.xml | 28 +- .../views/odoo_repository_branch.xml | 31 +- odoo_repository/views/odoo_repository_org.xml | 18 +- odoo_repository/views/res_config_settings.xml | 61 ++- odoo_repository/views/ssh_key.xml | 24 +- 47 files changed, 1092 insertions(+), 534 deletions(-) create mode 100644 odoo_repository/README.rst create mode 100644 odoo_repository/readme/CONTRIBUTORS.rst create mode 100644 odoo_repository/readme/DESCRIPTION.rst create mode 100644 odoo_repository/static/description/icon2.png create mode 100644 odoo_repository/static/description/icon2.xcf create mode 100644 odoo_repository/static/description/icon3.png create mode 100644 odoo_repository/static/description/icon4.png create mode 100644 odoo_repository/static/description/index.html diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst new file mode 100644 index 00000000..ca3c76ec --- /dev/null +++ b/odoo_repository/README.rst @@ -0,0 +1,61 @@ +====================== +Odoo Repositories Data +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b32f237cf038c4e5d365af1715911bf1774612d02be963dfe385ab23a334d224 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-camptocamp%2Fodoo--repository-lightgray.png?logo=github + :target: https://github.com/camptocamp/odoo-repository/tree/16.0/odoo_repository + :alt: camptocamp/odoo-repository + +|badge1| |badge2| |badge3| + +Base module to host data collected from Odoo repositories. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Camptocamp + * Sébastien Alix + +Maintainers +~~~~~~~~~~~ + +This module is part of the `camptocamp/odoo-repository `_ project on GitHub. + +You are welcome to contribute. diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 7f75f49e..8236c522 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -6,7 +6,7 @@ "version": "16.0.1.0.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", - "website": "https://github.com/OCA/TODO", + "website": "https://github.com/camptocamp/odoo-repository", "data": [ "security/ir.model.access.csv", "data/ir_cron.xml", diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 01d3fe50..89799e26 100644 --- a/odoo_repository/data/ir_cron.xml +++ b/odoo_repository/data/ir_cron.xml @@ -1,4 +1,4 @@ - + @@ -8,10 +8,13 @@ 1 days -1 - + - - + + code model.cron_scanner() diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml index 7d107d6d..33a14e5f 100644 --- a/odoo_repository/data/odoo_repository.xml +++ b/odoo_repository/data/odoo_repository.xml @@ -1,20 +1,23 @@ - + - + odoo https://github.com/odoo/odoo https://github.com/odoo/odoo github - + " + /> diff --git a/odoo_repository/data/odoo_repository_addons_path.xml b/odoo_repository/data/odoo_repository_addons_path.xml index dbbbce95..ac64b0c9 100644 --- a/odoo_repository/data/odoo_repository_addons_path.xml +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -1,27 +1,39 @@ - + - + ./odoo/addons - + - + ./addons - + - + . - - + + - + . - + diff --git a/odoo_repository/data/odoo_repository_org.xml b/odoo_repository/data/odoo_repository_org.xml index 4b6202d5..bd8ce0da 100644 --- a/odoo_repository/data/odoo_repository_org.xml +++ b/odoo_repository/data/odoo_repository_org.xml @@ -1,4 +1,4 @@ - + diff --git a/odoo_repository/data/queue_job.xml b/odoo_repository/data/queue_job.xml index 07208b9c..1bfb912c 100644 --- a/odoo_repository/data/queue_job.xml +++ b/odoo_repository/data/queue_job.xml @@ -1,4 +1,4 @@ - + @@ -20,7 +20,10 @@ - + action_find_pr_url diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index ca242fa1..ae99a35e 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -4,28 +4,22 @@ import contextlib import json import logging -import pathlib import os +import pathlib import subprocess import tempfile import time import git import oca_port -from .odoo_addons_analyzer import ModuleAnalysis +from .odoo_addons_analyzer import ModuleAnalysis # Disable logging from 'pygount' (used by odoo_addons_analyzer) logging.getLogger("pygount").setLevel(logging.ERROR) _logger = logging.getLogger(__name__) -# TODO handle Git clone/fetch through SSH: -# https://gitpython.readthedocs.io/en/stable/tutorial.html#handling-remotes -# ssh_cmd = 'ssh -i id_deployment_key' -# with repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd): -# repo.remotes.origin.fetch() - class BaseScanner: _dirname = "odoo-repositories" @@ -44,9 +38,7 @@ def __init__( self.name = name self.clone_url = clone_url self.branches = branches - self.repositories_path = self._prepare_repositories_path( - repositories_path - ) + self.repositories_path = self._prepare_repositories_path(repositories_path) self.path = self.repositories_path.joinpath(self.org, self.name) self._apply_git_config() self.ssh_key = ssh_key @@ -64,7 +56,7 @@ def _get_git_env(self): git_env = {} if self.ssh_key: with self._get_ssh_key() as ssh_key_path: - ssh_key_path = "/home/salix/.ssh/testing" # FIXME test + ssh_key_path = "/home/salix/.ssh/testing" # FIXME test git_ssh_cmd = f"ssh -o StrictHostKeyChecking=no -i {ssh_key_path}" git_env.update(GIT_SSH_COMMAND=git_ssh_cmd, GIT_TRACE="true") yield git_env @@ -96,9 +88,7 @@ def _prepare_repositories_path(self, repositories_path=None): def _apply_git_config(self): # This avoids too high memory consumption (default git config could # crash the Odoo workers when the scanner is run by Odoo itself). - subprocess.run( - ["git", "config", "--global", "core.packedGitLimit", "256m"] - ) + subprocess.run(["git", "config", "--global", "core.packedGitLimit", "256m"]) # self.repo.config_writer().set_value( # "core", "packedGitLimit", "256m").release() @@ -170,12 +160,12 @@ def _get_module_paths(self, relative_path, branch): ] def _get_module_paths_updated( - self, - relative_path, - from_commit, - to_commit, - branch, - ): + self, + relative_path, + from_commit, + to_commit, + branch, + ): """Return modules updated between `from_commit` and `to_commit`. It returns a list of tuples `[(module, last_commit), ...]`. @@ -206,17 +196,12 @@ def _get_module_paths_updated( # Remove the relative_path (e.g. 'addons/') from the diff path rel_path = pathlib.Path(relative_path) diff_path = pathlib.Path(diff.a_path) - module_path = pathlib.Path( - *diff_path.parts[:len(rel_path.parts) + 1] - ) + module_path = pathlib.Path(*diff_path.parts[: len(rel_path.parts) + 1]) tree = to_commit.tree / str(module_path) if self._odoo_module(tree): module_paths.add( # FIXME: should we return pathlib.Path objects? - ( - tree.path, - self._get_commit_of_git_tree(f"origin/{branch}", tree) - ) + (tree.path, self._get_commit_of_git_tree(f"origin/{branch}", tree)) ) return module_paths @@ -227,9 +212,7 @@ def _filter_file_path(self, path): return True def _get_commit_of_git_tree(self, ref, tree): - return tree.repo.git.log( - "--pretty=%H", "-n 1", ref, "--", tree.path - ) + return tree.repo.git.log("--pretty=%H", "-n 1", ref, "--", tree.path) def _odoo_module(self, tree): """Check if the `git.Tree` object is an Odoo module.""" @@ -255,12 +238,11 @@ def _get_subtree(self, tree, path): """Return the subtree `tree / path` if it exists, or `None`.""" try: return tree / path - except KeyError: + except KeyError: # pylint: disable=except-pass pass class MigrationScanner(BaseScanner): - def __init__( self, org: str, @@ -278,17 +260,17 @@ def __init__( self.migration_paths = migration_paths def scan(self): - super().scan() + res = super().scan() repo_id = self._get_odoo_repository_id() # Get the repository branches from Odoo as the ones we got as parameter # could not exist in the repository - branches = self._get_odoo_repository_branches(repo_id) + self._get_odoo_repository_branches(repo_id) for source_branch, target_branch in self.migration_paths: - if ( - self._branch_exists(source_branch) - and self._branch_exists(target_branch) + if self._branch_exists(source_branch) and self._branch_exists( + target_branch ): self._scan_migration_path(source_branch, target_branch) + return res def _scan_migration_path(self, source_branch, target_branch): repo = self.repo @@ -300,9 +282,8 @@ def _scan_migration_path(self, source_branch, target_branch): if not module_branch_id: _logger.warning( "Module '%s' for branch %s does not exist on Odoo, " - "a new scan of the repository is required. Aborted" % ( - module, source_branch - ) + "a new scan of the repository is required. Aborted" + % (module, source_branch) ) continue # For each module and source/target branch: @@ -318,9 +299,9 @@ def _scan_migration_path(self, source_branch, target_branch): repo_source_commit, module_source_tree ) module_target_commit = ( - module_target_tree and self._get_commit_of_git_tree( - repo_target_commit, module_target_tree - ) or False + module_target_tree + and self._get_commit_of_git_tree(repo_target_commit, module_target_tree) + or False ) # Retrieve existing migration data if any and check if it is outdated data = self._get_odoo_module_branch_migration_data( @@ -346,7 +327,7 @@ def _scan_module( source_branch: str, target_branch: str, source_commit: str, - target_commit: str + target_commit: str, ): """Collect the migration data of a module.""" # TODO if all the diffs from 'source_commit' to 'target_commit' @@ -417,12 +398,14 @@ def _get_odoo_module_branch_id(self, module, branch) -> int: raise NotImplementedError def _get_odoo_module_branch_migration_id( - self, module, source_branch, target_branch) -> int: + self, module, source_branch, target_branch + ) -> int: """Return the ID of 'odoo.module.branch.migration' record.""" raise NotImplementedError def _get_odoo_module_branch_migration_data( - self, module, source_branch, target_branch) -> dict: + self, module, source_branch, target_branch + ) -> dict: """Return the 'odoo.module.branch.migration' data.""" raise NotImplementedError @@ -436,7 +419,6 @@ def _push_scanned_data(self, module_branch_id, data): class RepositoryScanner(BaseScanner): - def __init__( self, org: str, @@ -454,11 +436,12 @@ def __init__( self.addons_paths_data = addons_paths_data def scan(self): - super().scan() + res = super().scan() repo_id = self._get_odoo_repository_id() branches_scanned = {} for branch in self.branches: branches_scanned[branch] = self._scan_branch(repo_id, branch) + return res def _scan_branch(self, repo_id, branch): if not self._branch_exists(branch): @@ -475,8 +458,11 @@ def _scan_branch(self, repo_id, branch): # Scan relevant subfolders of the repository for addons_path_data in self.addons_paths_data: self._scan_addons_path( - addons_path_data, branch, repo_branch_id, - last_fetched_commit, last_scanned_commit + addons_path_data, + branch, + repo_branch_id, + last_fetched_commit, + last_scanned_commit, ) # Flag this repository/branch as scanned self._update_last_scanned_commit(repo_branch_id, last_fetched_commit) @@ -484,8 +470,12 @@ def _scan_branch(self, repo_id, branch): return False def _scan_addons_path( - self, addons_path_data, branch, repo_branch_id, - last_fetched_commit, last_scanned_commit + self, + addons_path_data, + branch, + repo_branch_id, + last_fetched_commit, + last_scanned_commit, ): if not last_scanned_commit: module_paths = sorted( diff --git a/odoo_repository/models/odoo_author.py b/odoo_repository/models/odoo_author.py index 918f94c8..d2eeb8eb 100644 --- a/odoo_repository/models/odoo_author.py +++ b/odoo_repository/models/odoo_author.py @@ -12,9 +12,5 @@ class OdooAuthor(models.Model): name = fields.Char(required=True, index=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This author already exists." - ), + ("name_uniq", "UNIQUE (name)", "This author already exists."), ] diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 65d23cd3..08e385a2 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -10,16 +10,9 @@ class OdooBranch(models.Model): _order = "name" name = fields.Char(required=True, index=True) - odoo_version = fields.Boolean( - string="Odoo Version", - default=True, - ) - active = fields.Boolean(string="Active", default=True) + odoo_version = fields.Boolean(default=True) + active = fields.Boolean(default=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This branch already exists." - ), + ("name_uniq", "UNIQUE (name)", "This branch already exists."), ] diff --git a/odoo_repository/models/odoo_license.py b/odoo_repository/models/odoo_license.py index fd85f8d6..d6056865 100644 --- a/odoo_repository/models/odoo_license.py +++ b/odoo_repository/models/odoo_license.py @@ -12,9 +12,5 @@ class OdooLicense(models.Model): name = fields.Char(required=True, index=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This license already exists." - ), + ("name_uniq", "UNIQUE (name)", "This license already exists."), ] diff --git a/odoo_repository/models/odoo_maintainer.py b/odoo_repository/models/odoo_maintainer.py index f57d0ccc..2367369a 100644 --- a/odoo_repository/models/odoo_maintainer.py +++ b/odoo_repository/models/odoo_maintainer.py @@ -16,7 +16,8 @@ class OdooMaintainer(models.Model): module_branch_ids = fields.Many2many( comodel_name="odoo.module.branch", relation="module_branch_maintainer_rel", - column1="maintainer_id", column2="module_branch_id", + column1="maintainer_id", + column2="module_branch_id", string="Maintainers", ) @@ -26,9 +27,5 @@ def _compute_github_url(self): rec.github_url = f"{GITHUB_URL}/{rec.name}" _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This maintainer already exists." - ), + ("name_uniq", "UNIQUE (name)", "This maintainer already exists."), ] diff --git a/odoo_repository/models/odoo_module.py b/odoo_repository/models/odoo_module.py index f2a06bb0..7145ca3e 100644 --- a/odoo_repository/models/odoo_module.py +++ b/odoo_repository/models/odoo_module.py @@ -16,9 +16,5 @@ class OdooModule(models.Model): ) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This module technical name already exists." - ), + ("name_uniq", "UNIQUE (name)", "This module technical name already exists."), ] diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index b4c1e3b1..1d2631c6 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -22,7 +22,7 @@ class OdooModuleBranch(models.Model): ondelete="restrict", string="Technical name", required=True, - index=True + index=True, ) module_name = fields.Char( string="Module Technical Name", related="module_id.name", store=True @@ -31,7 +31,7 @@ class OdooModuleBranch(models.Model): comodel_name="odoo.repository.branch", ondelete="set null", string="Repository Branch", - index=True + index=True, ) repository_id = fields.Many2one( related="repository_branch_id.repository_id", @@ -79,7 +79,7 @@ class OdooModuleBranch(models.Model): store=True, index=True, ) - summary = fields.Char(string="Summary", index=True) + summary = fields.Char(index=True) category_id = fields.Many2one( comodel_name="odoo.module.category", ondelete="restrict", @@ -93,19 +93,22 @@ class OdooModuleBranch(models.Model): maintainer_ids = fields.Many2many( comodel_name="odoo.maintainer", relation="module_branch_maintainer_rel", - column1="module_branch_id", column2="maintainer_id", + column1="module_branch_id", + column2="maintainer_id", string="Maintainers", ) dependency_ids = fields.Many2many( comodel_name="odoo.module.branch", relation="module_branch_dependency_rel", - column1="module_branch_id", column2="dependency_id", + column1="module_branch_id", + column2="dependency_id", string="Dependencies", ) reverse_dependency_ids = fields.Many2many( comodel_name="odoo.module.branch", relation="module_branch_dependency_rel", - column1="dependency_id", column2="module_branch_id", + column1="dependency_id", + column2="module_branch_id", string="Reverse Dependencies", ) license_id = fields.Many2one( @@ -114,9 +117,7 @@ class OdooModuleBranch(models.Model): string="License", index=True, ) - version = fields.Char( - string="Version", - ) + version = fields.Char() development_status_id = fields.Many2one( comodel_name="odoo.module.dev.status", ondelete="restrict", @@ -128,14 +129,8 @@ class OdooModuleBranch(models.Model): comodel_name="odoo.python.dependency", string="Python Dependencies", ) - application = fields.Boolean( - string="Application", - default=False, - ) - installable = fields.Boolean( - string="Installable", - default=True, - ) + application = fields.Boolean(default=False) + installable = fields.Boolean(default=True) auto_install = fields.Boolean( string="Auto-Install", default=False, @@ -144,13 +139,13 @@ class OdooModuleBranch(models.Model): sloc_xml = fields.Integer("XML", help="XML source lines of code") sloc_js = fields.Integer("JS", help="JavaScript source lines of code") sloc_css = fields.Integer("CSS", help="CSS source lines of code") - last_scanned_commit = fields.Char(string="Last Scanned Commit") + last_scanned_commit = fields.Char() _sql_constraints = [ ( "module_id_branch_id_uniq", "UNIQUE (module_id, branch_id)", - "This module already exists for this branch." + "This module already exists for this branch.", ), ] @@ -158,8 +153,7 @@ class OdooModuleBranch(models.Model): def _compute_name(self): for rec in self: rec.name = ( - f"{rec.repository_branch_id.name or '?'}" - f" - {rec.module_id.name}" + f"{rec.repository_branch_id.name or '?'}" f" - {rec.module_id.name}" ) def action_find_pr_url(self): @@ -168,9 +162,7 @@ def action_find_pr_url(self): if self.pr_url or self.repository_branch_id: return False values = {"pr_url": False} - pr_urls = self._find_pr_urls_from_github( - self.branch_id, self.module_id - ) + pr_urls = self._find_pr_urls_from_github(self.branch_id, self.module_id) for pr_url in pr_urls: values["pr_url"] = pr_url # Get the relevant repository from PR URL if not yet defined @@ -195,8 +187,8 @@ def _find_pr_urls_from_github(self, branch, module): # Look for an open PR first, then unmerged (which includes closed ones) for pr_state in ("open", "unmerged"): url = ( - f'search/issues?q=is:pr+is:{pr_state}+base:{branch.name}' - f'+in:title+{module.name}' + f"search/issues?q=is:pr+is:{pr_state}+base:{branch.name}" + f"+in:title+{module.name}" ) try: # Mitigate 'API rate limit exceeded' GitHub API error @@ -204,21 +196,16 @@ def _find_pr_urls_from_github(self, branch, module): time.sleep(random.randrange(1, 5)) prs = github.request(self.env, url) except RuntimeError as exc: - raise RetryableJobError( - "Error while looking for PR URL") from exc + raise RetryableJobError("Error while looking for PR URL") from exc for pr in prs.get("items", []): yield pr["html_url"] def _find_repository_from_pr_url(self, pr_url): """Return the repository corresponding to `pr_url`.""" # Extract organization and repository name from PR url - path_parts = list( - filter(None, urlparse(pr_url).path.split('/')) - ) + path_parts = list(filter(None, urlparse(pr_url).path.split("/"))) org, repository = path_parts[:2] - repository_model = self.env["odoo.repository"].with_context( - active_test=False - ) + repository_model = self.env["odoo.repository"].with_context(active_test=False) return repository_model.search( [ ("org_id", "=", org), @@ -233,16 +220,12 @@ def push_scanned_data(self, repo_branch_id, module, data): manifest = data["manifest"] module = self._get_module(module) repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) - category_id = self._get_module_category_id( - manifest.get("category", "") - ) + category_id = self._get_module_category_id(manifest.get("category", "")) author_ids = self._get_author_ids(manifest.get("author", "")) maintainer_ids = self._get_maintainer_ids( tuple(manifest.get("maintainers", [])) ) - dev_status_id = self._get_dev_status_id( - manifest.get("development_status", "") - ) + dev_status_id = self._get_dev_status_id(manifest.get("development_status", "")) dependency_ids = self._get_dependency_ids( repo_branch, manifest.get("depends", []) ) @@ -256,9 +239,7 @@ def push_scanned_data(self, repo_branch_id, module, data): "branch_id": repo_branch.branch_id.id, "module_id": module.id, "title": manifest.get("name", False), - "summary": manifest.get( - "summary", manifest.get("description", False) - ), + "summary": manifest.get("summary", manifest.get("description", False)), "category_id": category_id, "author_ids": [(6, 0, author_ids)], "maintainer_ids": [(6, 0, maintainer_ids)], @@ -303,8 +284,10 @@ def _get_module_category_id(self, category_name): [("name", "=", category_name)], limit=1 ) if not rec: - rec = self.env["odoo.module.category"].sudo().create( - {"name": category_name} + rec = ( + self.env["odoo.module.category"] + .sudo() + .create({"name": category_name}) ) return rec.id return False @@ -319,8 +302,10 @@ def _get_author_ids(self, names): missing_author_names = set(names) - set(authors.mapped("name")) missing_authors = self.env["odoo.author"] if missing_author_names: - missing_authors = self.env["odoo.author"].sudo().create( - [{"name": name} for name in missing_author_names] + missing_authors = ( + self.env["odoo.author"] + .sudo() + .create([{"name": name} for name in missing_author_names]) ) return (authors | missing_authors).ids return [] @@ -328,12 +313,8 @@ def _get_author_ids(self, names): @tools.ormcache("names") def _get_maintainer_ids(self, names): if names: - maintainers = self.env["odoo.maintainer"].search( - [("name", "in", names)] - ) - missing_maintainer_names = ( - set(names) - set(maintainers.mapped("name")) - ) + maintainers = self.env["odoo.maintainer"].search([("name", "in", names)]) + missing_maintainer_names = set(names) - set(maintainers.mapped("name")) created = self.env["odoo.maintainer"] if missing_maintainer_names: created = created.sudo().create( @@ -349,9 +330,7 @@ def _get_dev_status_id(self, name): [("name", "=", name)], limit=1 ) if not rec: - rec = self.env["odoo.module.dev.status"].sudo().create( - {"name": name} - ) + rec = self.env["odoo.module.dev.status"].sudo().create({"name": name}) return rec.id return False @@ -381,9 +360,7 @@ def _get_python_dependency_ids(self, packages): dependencies = self.env["odoo.python.dependency"].search( [("name", "in", packages)] ) - missing_dependencies = ( - set(packages) - set(dependencies.mapped("name")) - ) + missing_dependencies = set(packages) - set(dependencies.mapped("name")) created = self.env["odoo.python.dependency"] if missing_dependencies: created = created.sudo().create( diff --git a/odoo_repository/models/odoo_module_category.py b/odoo_repository/models/odoo_module_category.py index b29ba17f..a9572089 100644 --- a/odoo_repository/models/odoo_module_category.py +++ b/odoo_repository/models/odoo_module_category.py @@ -11,9 +11,5 @@ class OdooModuleCategory(models.Model): name = fields.Char(required=True, index=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This module category already exists." - ), + ("name_uniq", "UNIQUE (name)", "This module category already exists."), ] diff --git a/odoo_repository/models/odoo_module_dev_status.py b/odoo_repository/models/odoo_module_dev_status.py index 8f3bfefb..908a3a1e 100644 --- a/odoo_repository/models/odoo_module_dev_status.py +++ b/odoo_repository/models/odoo_module_dev_status.py @@ -11,9 +11,5 @@ class OdooModuleDevStatus(models.Model): name = fields.Char(required=True, index=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This development_status already exists." - ), + ("name_uniq", "UNIQUE (name)", "This development_status already exists."), ] diff --git a/odoo_repository/models/odoo_python_dependency.py b/odoo_repository/models/odoo_python_dependency.py index ef97b2a0..27140253 100644 --- a/odoo_repository/models/odoo_python_dependency.py +++ b/odoo_repository/models/odoo_python_dependency.py @@ -12,9 +12,5 @@ class OdooPythonDependency(models.Model): name = fields.Char(required=True, index=True) _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This Python dependency already exists." - ), + ("name_uniq", "UNIQUE (name)", "This Python dependency already exists."), ] diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 054f06c2..4df60847 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -7,8 +7,8 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from odoo.addons.queue_job.exception import RetryableJobError from odoo.addons.queue_job.delay import chain +from odoo.addons.queue_job.exception import RetryableJobError from odoo.addons.queue_job.job import identity_exact from ..utils.scanner import RepositoryScannerOdooEnv @@ -37,7 +37,6 @@ class OdooRepository(models.Model): required=True, ) to_scan = fields.Boolean( - string="To Scan", default=True, help="Scan this repository to collect data.", ) @@ -50,14 +49,13 @@ class OdooRepository(models.Model): ("github", "GitHub"), ("gitlab", "GitLab"), ], - string="Repo Type", required=True, ) ssh_key_id = fields.Many2one( comodel_name="ssh.key", ondelete="restrict", string="SSH Key", - help="SSH key used to clone/fetch this repository." + help="SSH key used to clone/fetch this repository.", ) clone_branch_id = fields.Many2one( comodel_name="odoo.branch", @@ -72,11 +70,11 @@ class OdooRepository(models.Model): string="Odoo Version", domain=[("odoo_version", "=", True)], ) - active = fields.Boolean(string="Active", default=True) + active = fields.Boolean(default=True) addons_path_ids = fields.Many2many( comodel_name="odoo.repository.addons_path", string="Addons Path", - help="Relative path of folders in this repository hosting Odoo modules" + help="Relative path of folders in this repository hosting Odoo modules", ) branch_ids = fields.One2many( comodel_name="odoo.repository.branch", @@ -95,7 +93,7 @@ def default_get(self, fields_list): 4, self.env.ref( "odoo_repository.odoo_repository_addons_path_community" - ).id + ).id, ) ] return res @@ -109,7 +107,7 @@ def _compute_github_url(self): ( "org_id_name_repository_id_uniq", "UNIQUE (org_id, name, odoo_version_id)", - "This repository already exists." + "This repository already exists.", ), ] @@ -156,9 +154,7 @@ def _check_config(self): raise UserError( _( "Please define the '{key}' system parameter to " - "clone repositories in the folder of your choice.".format( - key=key - ) + "clone repositories in the folder of your choice.".format(key=key) ) ) # Ensure the folder exists @@ -198,7 +194,7 @@ def _create_jobs(self, branches): for branch in branches: delayable = self.delayable( description=f"Scan {self.display_name}#{branch}", - identity_key=identity_exact + identity_key=identity_exact, ) job = delayable._scan_branch(branch) jobs.append(job) @@ -217,8 +213,7 @@ def _prepare_scanner_parameters(self, branch): ir_config = self.env["ir.config_parameter"] repositories_path = ir_config.get_param(self._repositories_path_key) github_token = ir_config.get_param( - "odoo_repository_github_token", - os.environ.get("GITHUB_TOKEN") + "odoo_repository_github_token", os.environ.get("GITHUB_TOKEN") ) return { "org": self.org_id.name, @@ -236,7 +231,7 @@ def _prepare_scanner_parameters(self, branch): "repositories_path": repositories_path, "ssh_key": self.ssh_key_id.private_key, "github_token": github_token, - "env": self.env + "env": self.env, } def action_force_scan(self, branches=None): diff --git a/odoo_repository/models/odoo_repository_addons_path.py b/odoo_repository/models/odoo_repository_addons_path.py index 31260594..7c8d192a 100644 --- a/odoo_repository/models/odoo_repository_addons_path.py +++ b/odoo_repository/models/odoo_repository_addons_path.py @@ -35,6 +35,6 @@ class OdooRepositoryAddonsPath(models.Model): ( "addons_path_uniq", "UNIQUE (relative_path, is_standard, is_enterprise, is_community)", - "This addons-path already exists." + "This addons-path already exists.", ), ] diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index b9bc1043..2bf728b1 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -37,7 +37,7 @@ class OdooRepositoryBranch(models.Model): ( "repository_id_branch_id_uniq", "UNIQUE (repository_id, branch_id)", - "This branch already exists for this repository." + "This branch already exists for this repository.", ), ] diff --git a/odoo_repository/models/odoo_repository_org.py b/odoo_repository/models/odoo_repository_org.py index f13ecb25..38605aa2 100644 --- a/odoo_repository/models/odoo_repository_org.py +++ b/odoo_repository/models/odoo_repository_org.py @@ -19,9 +19,5 @@ def _compute_github_url(self): rec.github_url = f"{GITHUB_URL}/{rec.name}" _sql_constraints = [ - ( - "name_uniq", - "UNIQUE (name)", - "This organization already exists." - ), + ("name_uniq", "UNIQUE (name)", "This organization already exists."), ] diff --git a/odoo_repository/models/ssh_key.py b/odoo_repository/models/ssh_key.py index 810fc6b1..10611331 100644 --- a/odoo_repository/models/ssh_key.py +++ b/odoo_repository/models/ssh_key.py @@ -9,7 +9,4 @@ class SSHKey(models.Model): _description = "SSH private key" name = fields.Char(required=True) - private_key = fields.Text( - required=True, - help="SSH private key without passphrase." - ) + private_key = fields.Text(required=True, help="SSH private key without passphrase.") diff --git a/odoo_repository/readme/CONTRIBUTORS.rst b/odoo_repository/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..a0c91e35 --- /dev/null +++ b/odoo_repository/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Camptocamp + * Sébastien Alix diff --git a/odoo_repository/readme/DESCRIPTION.rst b/odoo_repository/readme/DESCRIPTION.rst new file mode 100644 index 00000000..1cfa8cc0 --- /dev/null +++ b/odoo_repository/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Base module to host data collected from Odoo repositories. diff --git a/odoo_repository/static/description/icon2.png b/odoo_repository/static/description/icon2.png new file mode 100644 index 0000000000000000000000000000000000000000..15acf0c1db384b81299698cd2e6b4212bacfd46b GIT binary patch literal 5336 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ʮ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 literal 0 HcmV?d00001 diff --git a/odoo_repository/static/description/icon3.png b/odoo_repository/static/description/icon3.png new file mode 100644 index 0000000000000000000000000000000000000000..e9773c507001d2f23c2cf70037f06f5c78aab6cb GIT binary patch literal 28101 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 IoK?jC0fsb9m;e9( literal 0 HcmV?d00001 diff --git a/odoo_repository/static/description/icon4.png b/odoo_repository/static/description/icon4.png new file mode 100644 index 0000000000000000000000000000000000000000..e07bbb337531b9c7bf68436f9a3768eec750813f GIT binary patch literal 28146 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 Qh#QKMoVskKG$ic*01CeLsQ>@~ literal 0 HcmV?d00001 diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html new file mode 100644 index 00000000..2a85d00d --- /dev/null +++ b/odoo_repository/static/description/index.html @@ -0,0 +1,417 @@ + + + + + + +Odoo Repositories Data + + + +
+

Odoo Repositories Data

+ + +

Beta License: AGPL-3 camptocamp/odoo-repository

+

Base module to host data collected from Odoo repositories.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the camptocamp/odoo-repository project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/odoo_repository/utils/github.py b/odoo_repository/utils/github.py index 1b52e96d..80c8b3de 100644 --- a/odoo_repository/utils/github.py +++ b/odoo_repository/utils/github.py @@ -13,9 +13,8 @@ def request(env, url, method="get", params=None, json=None): """Request GitHub API.""" headers = {"Accept": "application/vnd.github.groot-preview+json"} key = "odoo_repository_github_token" - token = ( - env["ir.config_parameter"].get_param(key, "") - or os.environ.get("GITHUB_TOKEN") + token = env["ir.config_parameter"].get_param(key, "") or os.environ.get( + "GITHUB_TOKEN" ) if token: headers.update({"Authorization": f"token {token}"}) diff --git a/odoo_repository/utils/scanner.py b/odoo_repository/utils/scanner.py index c7641270..e119ab64 100644 --- a/odoo_repository/utils/scanner.py +++ b/odoo_repository/utils/scanner.py @@ -17,9 +17,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _get_odoo_repository_id(self): - return self.env["odoo.repository"].search( - [("name", "=", self.name), ("org_id", "=", self.org)] - ).id + return ( + self.env["odoo.repository"] + .search([("name", "=", self.name), ("org_id", "=", self.org)]) + .id + ) def _get_odoo_branch_id(self, repo_id, branch): repo = self.env["odoo.repository"].browse(repo_id) @@ -40,9 +42,7 @@ def _get_odoo_repository_branch_id(self, repo_id, branch_id): return repo_branch.id def _create_odoo_repository_branch(self, repo_id, branch_id): - repo_branch_id = self._get_odoo_repository_branch_id( - repo_id, branch_id - ) + repo_branch_id = self._get_odoo_repository_branch_id(repo_id, branch_id) if not repo_branch_id: values = { "repository_id": repo_id, @@ -71,7 +71,7 @@ def _push_scanned_data(self, repo_branch_id, module, data): repo_branch_id, module, data ) # Commit after each module - self.env.cr.commit() + self.env.cr.commit() # pylint: disable=invalid-commit return res def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): @@ -79,5 +79,5 @@ def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): repo_branch = repo_branch_model.browse(repo_branch_id) repo_branch.last_scanned_commit = last_fetched_commit # Commit after each repository/branch - self.env.cr.commit() + self.env.cr.commit() # pylint: disable=invalid-commit return True diff --git a/odoo_repository/views/menu.xml b/odoo_repository/views/menu.xml index 98d3d005..dde51422 100644 --- a/odoo_repository/views/menu.xml +++ b/odoo_repository/views/menu.xml @@ -1,16 +1,24 @@ - + - - - + + + diff --git a/odoo_repository/views/odoo_author.xml b/odoo_repository/views/odoo_author.xml index 4610d14d..08363c24 100644 --- a/odoo_repository/views/odoo_author.xml +++ b/odoo_repository/views/odoo_author.xml @@ -1,4 +1,4 @@ - + @@ -10,7 +10,7 @@
- +
@@ -22,7 +22,7 @@ odoo.author - +
@@ -33,7 +33,7 @@ search - +
@@ -42,11 +42,13 @@ Authors ir.actions.act_window odoo.author - + - +
diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index e3e17fe1..f2642183 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -1,4 +1,4 @@ - + @@ -8,8 +8,8 @@ odoo.branch - - + + @@ -20,10 +20,13 @@ search - - - + + + @@ -32,11 +35,13 @@ Branches ir.actions.act_window odoo.branch - + - + diff --git a/odoo_repository/views/odoo_license.xml b/odoo_repository/views/odoo_license.xml index 6e70ce8a..9eed126b 100644 --- a/odoo_repository/views/odoo_license.xml +++ b/odoo_repository/views/odoo_license.xml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ odoo.license - + @@ -19,7 +19,7 @@ search - + @@ -28,11 +28,13 @@ Licenses ir.actions.act_window odoo.license - + - + diff --git a/odoo_repository/views/odoo_maintainer.xml b/odoo_repository/views/odoo_maintainer.xml index 4405c43c..f15d58ee 100644 --- a/odoo_repository/views/odoo_maintainer.xml +++ b/odoo_repository/views/odoo_maintainer.xml @@ -1,4 +1,4 @@ - + @@ -10,19 +10,19 @@
- + - - - - - - - - + + + + + + + + @@ -36,8 +36,8 @@ odoo.maintainer - - + + @@ -48,7 +48,7 @@ search - + @@ -57,11 +57,13 @@ Maintainers ir.actions.act_window odoo.maintainer - + - + diff --git a/odoo_repository/views/odoo_module.xml b/odoo_repository/views/odoo_module.xml index cf45de16..a75b4c0c 100644 --- a/odoo_repository/views/odoo_module.xml +++ b/odoo_repository/views/odoo_module.xml @@ -1,4 +1,4 @@ - + @@ -10,19 +10,19 @@ - + - - - - - - - - + + + + + + + + @@ -36,7 +36,7 @@ odoo.module - + @@ -47,7 +47,7 @@ search - + @@ -56,11 +56,13 @@ Module Technical Names ir.actions.act_window odoo.module - + - + diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index 5fddec00..8b8135f0 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -1,4 +1,4 @@ - + @@ -11,35 +11,38 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - + + + + @@ -47,27 +50,27 @@ - - - - - + + + + + - - - - - + + + + + - + @@ -80,30 +83,30 @@ odoo.module.branch - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -114,47 +117,89 @@ search - - - - - - - - - + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - + + + + + + @@ -164,12 +209,14 @@ Modules ir.actions.act_window odoo.module.branch - + {'search_default_installable': 1} - + diff --git a/odoo_repository/views/odoo_module_category.xml b/odoo_repository/views/odoo_module_category.xml index 88144f8c..63eedb6d 100644 --- a/odoo_repository/views/odoo_module_category.xml +++ b/odoo_repository/views/odoo_module_category.xml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ odoo.module.category - + @@ -19,7 +19,7 @@ search - + @@ -28,11 +28,13 @@ Categories ir.actions.act_window odoo.module.category - + - + diff --git a/odoo_repository/views/odoo_module_dev_status.xml b/odoo_repository/views/odoo_module_dev_status.xml index 40247669..9cc0dc00 100644 --- a/odoo_repository/views/odoo_module_dev_status.xml +++ b/odoo_repository/views/odoo_module_dev_status.xml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ odoo.module.dev.status - + @@ -19,7 +19,7 @@ search - + @@ -28,11 +28,13 @@ Development Status ir.actions.act_window odoo.module.dev.status - + - + diff --git a/odoo_repository/views/odoo_python_dependency.xml b/odoo_repository/views/odoo_python_dependency.xml index c1c8a41d..95d8299c 100644 --- a/odoo_repository/views/odoo_python_dependency.xml +++ b/odoo_repository/views/odoo_python_dependency.xml @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ odoo.python.dependency - + @@ -19,7 +19,7 @@ search - + @@ -28,11 +28,13 @@ Python Dependencies ir.actions.act_window odoo.python.dependency - + - + diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index 8318428c..418f12f7 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -1,4 +1,4 @@ - + @@ -9,67 +9,100 @@
-
- - - - + + + + - - - + + + - - - + + + }" + /> - + - - - - + + + + - + - - - + + + @@ -84,11 +117,11 @@ odoo.repository - - - - - + + + + + @@ -99,13 +132,16 @@ search - - - - + + + + - + @@ -115,11 +151,13 @@ Repositories ir.actions.act_window odoo.repository - + - +
diff --git a/odoo_repository/views/odoo_repository_addons_path.xml b/odoo_repository/views/odoo_repository_addons_path.xml index 7e273c2e..b49cd638 100644 --- a/odoo_repository/views/odoo_repository_addons_path.xml +++ b/odoo_repository/views/odoo_repository_addons_path.xml @@ -1,4 +1,4 @@ - + @@ -10,10 +10,10 @@ - - - - + + + + @@ -25,10 +25,10 @@ odoo.repository.addons_path - - - - + + + + @@ -37,11 +37,13 @@ Addons Path ir.actions.act_window odoo.repository.addons_path - + - + diff --git a/odoo_repository/views/odoo_repository_branch.xml b/odoo_repository/views/odoo_repository_branch.xml index 258a9f75..26711933 100644 --- a/odoo_repository/views/odoo_repository_branch.xml +++ b/odoo_repository/views/odoo_repository_branch.xml @@ -1,4 +1,4 @@ - + @@ -9,20 +9,23 @@
-
- - - + + + - +
@@ -34,8 +37,8 @@ odoo.repository.branch - - + + @@ -46,8 +49,8 @@ search - - + + diff --git a/odoo_repository/views/odoo_repository_org.xml b/odoo_repository/views/odoo_repository_org.xml index 25427fc0..0a46814c 100644 --- a/odoo_repository/views/odoo_repository_org.xml +++ b/odoo_repository/views/odoo_repository_org.xml @@ -1,4 +1,4 @@ - + @@ -10,7 +10,7 @@
- +
@@ -22,7 +22,7 @@ odoo.repository.org - + @@ -33,7 +33,7 @@ search - + @@ -42,11 +42,13 @@ Repository Organizations ir.actions.act_window odoo.repository.org - + - +
diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 7d908390..3f863a04 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -1,4 +1,4 @@ - + @@ -6,17 +6,22 @@ res.config.settings.form.inherit res.config.settings - + -
+

Odoo Repositories

-
+
Storage local path
@@ -24,14 +29,19 @@
- +
-
+
GitHub token (API)
@@ -39,13 +49,16 @@
- +
- Another way is to define the GITHUB_TOKEN environment variable on your system. + Another way is to define the GITHUB_TOKEN environment variable on your system.
@@ -59,17 +72,19 @@ Settings ir.actions.act_window res.config.settings - + form inline {'module' : 'odoo_repository', 'bin_size': False} - + diff --git a/odoo_repository/views/ssh_key.xml b/odoo_repository/views/ssh_key.xml index 37f93511..951c6bb1 100644 --- a/odoo_repository/views/ssh_key.xml +++ b/odoo_repository/views/ssh_key.xml @@ -1,4 +1,4 @@ - + @@ -6,13 +6,13 @@ ssh.key.form ssh.key - +
- - + +
@@ -24,7 +24,7 @@ ssh.key - +
@@ -35,7 +35,7 @@ search - + @@ -44,12 +44,14 @@ SSH Keys ir.actions.act_window ssh.key - - + + - +
From 4020ba1fd16fb91f1ff3c8983f7b78bfd30062ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 10 Oct 2023 11:09:29 +0200 Subject: [PATCH 006/160] odoo_repository: sync data from main node --- odoo_repository/__init__.py | 1 + odoo_repository/controllers/__init__.py | 1 + odoo_repository/controllers/main.py | 32 ++++ odoo_repository/data/ir_cron.xml | 16 ++ odoo_repository/models/odoo_module_branch.py | 73 ++++++++ odoo_repository/models/odoo_repository.py | 165 +++++++++++++++++- odoo_repository/models/res_config_settings.py | 3 + odoo_repository/views/res_config_settings.xml | 17 ++ 8 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 odoo_repository/controllers/__init__.py create mode 100644 odoo_repository/controllers/main.py diff --git a/odoo_repository/__init__.py b/odoo_repository/__init__.py index 0650744f..f7209b17 100644 --- a/odoo_repository/__init__.py +++ b/odoo_repository/__init__.py @@ -1 +1,2 @@ from . import models +from . import controllers diff --git a/odoo_repository/controllers/__init__.py b/odoo_repository/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/odoo_repository/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/odoo_repository/controllers/main.py b/odoo_repository/controllers/main.py new file mode 100644 index 00000000..78d2496b --- /dev/null +++ b/odoo_repository/controllers/main.py @@ -0,0 +1,32 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json + +from odoo import http + + +class OdooRepository(http.Controller): + @http.route("/odoo-repository/data", type="http", auth="none", csrf=False) + def index(self, orgs: str = None, repositories: str = None, branches: str = None): + """Returns modules data as JSON. + + This endpoint is used by secondary nodes that want to sync the data + collected by the main node. + + Parameters are strings that can be set with multiple values separated + by commas, e.g. `branches="15.0,16.0"`. + """ + if orgs: + orgs = orgs.split(",") + if repositories: + repositories = repositories.split(",") + if branches: + branches = branches.split(",") + data = ( + http.request.env["odoo.module.branch"] + .sudo() + ._get_modules_data(orgs=orgs, repositories=repositories, branches=branches) + ) + headers = {"Content-Type": "application/json"} + return http.request.make_response(json.dumps(data), headers) diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 89799e26..6c5e8ca0 100644 --- a/odoo_repository/data/ir_cron.xml +++ b/odoo_repository/data/ir_cron.xml @@ -19,4 +19,20 @@ model.cron_scanner() + + Odoo Repositories - Fetch data from main node + 1 + days + -1 + + + + + code + model.cron_fetch_data() + + diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 1d2631c6..e2a88220 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -385,3 +385,76 @@ def _get_module(self, name): if not module: module = self.env["odoo.module"].sudo().create({"name": name}) return module + + # TODO adds ormcache + def _get_modules_data(self, orgs=None, repositories=None, branches=None): + """Returns modules data matching the criteria. + + E.g.: + + >>> self._get_modules_data( + ... orgs=['OCA'], + ... repositories=['server-env'], + ... branches=['15.0', '16.0'], + ... ) + + """ + domain = self._get_modules_domain(orgs, repositories, branches) + modules = self.search(domain) + data = [] + for module in modules: + data.append(module._to_dict()) + return data + + def _get_modules_domain(self, orgs=None, repositories=None, branches=None): + domain = [ + # Do not return orphans modules + ("org_id", "!=", False), + ("repository_id", "!=", False), + ("branch_id", "!=", False), + ] + if orgs: + domain.append(("org_id", "in", orgs)) + if repositories: + domain.append(("repository_id", "in", repositories)) + if branches: + domain.append(("branch_id", "in", branches)) + return domain + + def _to_dict(self): + """Convert module data to a dictionary.""" + self.ensure_one() + return { + "module": self.module_name, + "branch": self.branch_id.name, + "repository": { + "org": self.repository_id.org_id.name, + "name": self.repository_id.name, + "repo_url": self.repository_id.repo_url, + "repo_type": self.repository_id.repo_type, + "active": self.repository_id.active, + "last_scanned_commit": self.repository_branch_id.last_scanned_commit, + }, + "title": self.title, + "summary": self.summary, + "authors": self.author_ids.mapped("name"), + "maintainers": self.maintainer_ids.mapped("name"), + "depends": self.dependency_ids.mapped("module_name"), + "category": self.category_id.name, + "license": self.license_id.name, + "version": self.version, + "development_status": self.development_status_id.name, + "application": self.application, + "installable": self.installable, + "auto_install": self.auto_install, + "external_dependencies": self.external_dependencies, + "is_standard": self.is_standard, + "is_enterprise": self.is_enterprise, + "is_community": self.is_community, + "sloc_python": self.sloc_python, + "sloc_xml": self.sloc_xml, + "sloc_js": self.sloc_js, + "sloc_css": self.sloc_css, + "last_scanned_commit": self.last_scanned_commit, + "pr_url": self.pr_url, + } diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 4df60847..9667ba2c 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -1,10 +1,13 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import json import os import pathlib -from odoo import _, api, fields, models +import requests + +from odoo import _, api, fields, models, tools from odoo.exceptions import UserError from odoo.addons.queue_job.delay import chain @@ -242,3 +245,163 @@ def action_force_scan(self, branches=None): """ self.ensure_one() return self.action_scan(branches=branches, force=True) + + @api.model + def cron_fetch_data(self, branches=None, force=False): + """Fetch Odoo repositories data from the main node (if any).""" + main_node_url = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("odoo_repository_main_node_url") + ) + if not main_node_url: + return False + branches = self.env["odoo.branch"].search([("odoo_version", "=", True)]) + branch_names = ",".join(branches.mapped("name")) + url = f"{main_node_url}?branches=%s" % branch_names + try: + response = requests.get(url, timeout=60) + except Exception as exc: + raise UserError(_("Unable to fetch data from %s") % main_node_url) from exc + else: + if response.status_code == 200: + try: + data = json.loads(response.text) + except json.decoder.JSONDecodeError as exc: + raise UserError( + _("Unable to decode data received from %s") % main_node_url + ) from exc + else: + self._import_data(data) + + def _import_data(self, data): + for module_data in data: + # TODO Move these methods to 'odoo.module.branch'? + values = self._prepare_module_branch_values(module_data) + self._create_or_update_module_branch(values) + + def _prepare_module_branch_values(self, data): + # Get branch, repository and technical module + branch = self.env["odoo.branch"].search( + [("odoo_version", "=", True), ("name", "=", data["branch"])] + ) + org = self._get_repository_org(data["repository"]["org"]) + repository = self._get_repository( + org.id, data["repository"]["name"], data["repository"] + ) + repository_branch = self._get_repository_branch( + org.id, repository.id, branch.id, data["repository"] + ) + + mb_model = self.env["odoo.module.branch"] + module = mb_model._get_module(data["module"]) + # Prepare values + category_id = mb_model._get_module_category_id(data["category"]) + author_ids = mb_model._get_author_ids(tuple(data["authors"])) + maintainer_ids = mb_model._get_maintainer_ids(tuple(data["maintainers"])) + dev_status_id = mb_model._get_dev_status_id(data["development_status"]) + dependency_ids = mb_model._get_dependency_ids( + repository_branch, data["depends"] + ) + external_dependencies = data["external_dependencies"] + python_dependency_ids = mb_model._get_python_dependency_ids( + tuple(external_dependencies.get("python", [])) + ) + license_id = mb_model._get_license_id(data["license"]) + values = { + "repository_branch_id": repository_branch.id, + "branch_id": repository_branch.branch_id.id, + "module_id": module.id, + "title": data["title"], + "summary": data["summary"], + "category_id": category_id, + "author_ids": [(6, 0, author_ids)], + "maintainer_ids": [(6, 0, maintainer_ids)], + "dependency_ids": [(6, 0, dependency_ids)], + "external_dependencies": external_dependencies, + "python_dependency_ids": [(6, 0, python_dependency_ids)], + "license_id": license_id, + "version": data["version"], + "development_status_id": dev_status_id, + "installable": data["installable"], + "auto_install": data["auto_install"], + "application": data["application"], + "is_standard": data["is_standard"], + "is_enterprise": data["is_enterprise"], + "is_community": data["is_community"], + "sloc_python": data["sloc_python"], + "sloc_xml": data["sloc_xml"], + "sloc_js": data["sloc_js"], + "sloc_css": data["sloc_css"], + "last_scanned_commit": data["last_scanned_commit"], + "pr_url": data["pr_url"], + } + return values + + def _create_or_update_module_branch(self, values): + mb_model = self.env["odoo.module.branch"] + rec = mb_model.search( + [ + ("module_id", "=", values["module_id"]), + # Module could have been already created to satisfy dependencies + # (without 'repository_branch_id' set) + "|", + ("repository_branch_id", "=", values["repository_branch_id"]), + ("branch_id", "=", values["branch_id"]), + ], + limit=1, + ) + if rec: + rec.sudo().write(values) + else: + rec = mb_model.sudo().create(values) + return rec + + @tools.ormcache("name") + def _get_repository_org(self, name): + rec = self.env["odoo.repository.org"].search([("name", "=", name)], limit=1) + if not rec: + rec = self.env["odoo.repository.org"].sudo().create({"name": name}) + return rec + + @tools.ormcache("org_id", "name") + def _get_repository(self, org_id, name, data): + rec = self.env["odoo.repository"].search( + [ + ("org_id", "=", org_id), + ("name", "=", name), + ], + limit=1, + ) + values = { + "org_id": org_id, + "name": name, + "repo_url": data["repo_url"], + "repo_type": data["repo_type"], + "active": data["active"], + } + if rec: + rec.sudo().write(values) + else: + rec = self.env["odoo.repository"].sudo().create(values) + return rec + + @tools.ormcache("org_id", "repository_id", "branch_id") + def _get_repository_branch(self, org_id, repository_id, branch_id, data): + rec = self.env["odoo.repository.branch"].search( + [ + ("repository_id", "=", repository_id), + ("branch_id", "=", branch_id), + ], + limit=1, + ) + values = { + "repository_id": repository_id, + "branch_id": branch_id, + "last_scanned_commit": data["last_scanned_commit"], + } + if rec: + rec.sudo().write(values) + else: + rec = self.env["odoo.repository.branch"].sudo().create(values) + return rec diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py index ebf64cf4..6eb02e4c 100644 --- a/odoo_repository/models/res_config_settings.py +++ b/odoo_repository/models/res_config_settings.py @@ -13,3 +13,6 @@ class ResConfigSettings(models.TransientModel): config_odoo_repository_github_token = fields.Char( string="GitHub Token", config_parameter="odoo_repository_github_token" ) + config_odoo_repository_main_node_url = fields.Char( + string="Endpoint URL", config_parameter="odoo_repository_main_node_url" + ) diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 3f863a04..592b3d24 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -62,6 +62,23 @@
+
+
+ Main Node +
+ Endpoint URL of the main node from which modules data can be fetched. +
+
+
+ +
+
+
+
From d9d979346138444b48267c22c02259e95fb70b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sat, 4 Nov 2023 16:55:11 +0100 Subject: [PATCH 007/160] odoo_repository: sort on 'sequence' for 'odoo.repository' --- odoo_repository/data/odoo_repository.xml | 1 + odoo_repository/models/odoo_repository.py | 3 ++- odoo_repository/views/odoo_repository.xml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml index 33a14e5f..f8c20f8e 100644 --- a/odoo_repository/data/odoo_repository.xml +++ b/odoo_repository/data/odoo_repository.xml @@ -6,6 +6,7 @@ odoo + https://github.com/odoo/odoo https://github.com/odoo/odoo github diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 9667ba2c..5f15181f 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -20,12 +20,13 @@ class OdooRepository(models.Model): _name = "odoo.repository" _description = "Odoo Modules Repository" - _order = "display_name" + _order = "sequence, display_name" _repositories_path_key = "odoo_repository_storage_path" display_name = fields.Char(compute="_compute_display_name", store=True) active = fields.Boolean(default=True) + sequence = fields.Integer(default=100) org_id = fields.Many2one( comodel_name="odoo.repository.org", ondelete="cascade", diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index 418f12f7..aec63f43 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -39,6 +39,7 @@ attrs="{'readonly': [('branch_ids', '!=', [])]}" /> + Date: Wed, 8 Nov 2023 12:55:10 +0100 Subject: [PATCH 008/160] odoo_repository: update 'unmerged_pr' filter label --- odoo_repository/views/odoo_module_branch.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index 8b8135f0..83f47943 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -161,7 +161,7 @@ Date: Sun, 19 Nov 2023 19:02:39 +0100 Subject: [PATCH 009/160] BaseScanner: rename '_get_commit_of_git_tree' method to '_get_last_commit_of_git_tree' --- odoo_repository/lib/scanner.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index ae99a35e..ea957c80 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -154,7 +154,7 @@ def _get_module_paths(self, relative_path, branch): if relative_tree_path: addons_trees = (branch_commit.tree / relative_tree_path).trees return [ - (tree.path, self._get_commit_of_git_tree(f"origin/{branch}", tree)) + (tree.path, self._get_last_commit_of_git_tree(f"origin/{branch}", tree)) for tree in addons_trees if self._odoo_module(tree) ] @@ -201,7 +201,10 @@ def _get_module_paths_updated( if self._odoo_module(tree): module_paths.add( # FIXME: should we return pathlib.Path objects? - (tree.path, self._get_commit_of_git_tree(f"origin/{branch}", tree)) + ( + tree.path, + self._get_last_commit_of_git_tree(f"origin/{branch}", tree), + ) ) return module_paths @@ -211,7 +214,7 @@ def _filter_file_path(self, path): return False return True - def _get_commit_of_git_tree(self, ref, tree): + def _get_last_commit_of_git_tree(self, ref, tree): return tree.repo.git.log("--pretty=%H", "-n 1", ref, "--", tree.path) def _odoo_module(self, tree): @@ -295,12 +298,14 @@ def _scan_migration_path(self, source_branch, target_branch): module_target_tree = self._get_subtree( repo.commit(repo_target_commit).tree, module ) - module_source_commit = self._get_commit_of_git_tree( + module_source_commit = self._get_last_commit_of_git_tree( repo_source_commit, module_source_tree ) module_target_commit = ( module_target_tree - and self._get_commit_of_git_tree(repo_target_commit, module_target_tree) + and self._get_last_commit_of_git_tree( + repo_target_commit, module_target_tree + ) or False ) # Retrieve existing migration data if any and check if it is outdated From 5642bd2fe7504dd7299d925da765bc219c4622d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 19 Nov 2023 19:10:02 +0100 Subject: [PATCH 010/160] MigrationScanner: check is scanning the module is relevant If new commits since the last scan are updating irrelevant files regarding the collection of migration data (like PO files updated automatically by Weblate), then we skip the scan as this is a process that can be quite time consuming. Even if the scan has been skipped we still push the last source and target commits to Odoo. --- odoo_repository/lib/scanner.py | 113 ++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 15 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index ea957c80..f237e5b7 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -20,6 +20,10 @@ _logger = logging.getLogger(__name__) +# Paths ending with these patterns will be ignored such as if all scanned commits +# update such files, the underlying module won't be scanned to preserve resources. +IGNORE_FILES = [".po", ".pot", "README.rst", "index.html"] + class BaseScanner: _dirname = "odoo-repositories" @@ -217,6 +221,12 @@ def _filter_file_path(self, path): def _get_last_commit_of_git_tree(self, ref, tree): return tree.repo.git.log("--pretty=%H", "-n 1", ref, "--", tree.path) + def _get_commits_of_git_tree(self, from_, to_, tree): + commits = tree.repo.git.log( + "--pretty=%H", "-r", f"{from_}..{to_}", "--", tree.path + ) + return commits.split() + def _odoo_module(self, tree): """Check if the `git.Tree` object is an Odoo module.""" # NOTE: it seems we could have data only modules without '__init__.py' @@ -323,6 +333,8 @@ def _scan_migration_path(self, source_branch, target_branch): target_branch, module_source_commit, module_target_commit, + data.get("last_source_scanned_commit"), + data.get("last_target_scanned_commit"), ) def _scan_module( @@ -333,28 +345,99 @@ def _scan_module( target_branch: str, source_commit: str, target_commit: str, + source_last_scanned_commit: str, + target_last_scanned_commit: str, ): """Collect the migration data of a module.""" - # TODO if all the diffs from 'source_commit' to 'target_commit' - # for the current module relates to unrelevant files (po, rst, html) - # skip the scan to speed up the process and push only last scanned commits. - # OCA bots and weblate could update modules in batch to change such files, - # making the scan of all repositories quite long. - data = self._run_oca_port(module, source_branch, target_branch) - data.update( - { - "module": module, - "source_branch": source_branch, - "target_branch": target_branch, - "source_commit": source_commit, - "target_commit": target_commit, - } + data = { + "module": module, + "source_branch": source_branch, + "target_branch": target_branch, + "source_commit": source_commit, + "target_commit": target_commit, + } + # If files updated in the module since the last scan are not relevant + # (e.g. all new commits are updating PO files), we skip the scan but + # we still push the new source/target commits to Odoo. + scan_relevant = self._is_scan_module_relevant( + module, + source_commit, + target_commit, + source_last_scanned_commit, + target_last_scanned_commit, ) + if scan_relevant: + _logger.info( + "%s: relevant changes detected in '%s' (%s -> %s)", + self.full_name, + module, + source_branch, + target_branch, + ) + oca_port_data = self._run_oca_port(module, source_branch, target_branch) + data.update(oca_port_data) self._push_scanned_data(module_branch_id, data) # Mitigate "GH API rate limit exceeds" error - time.sleep(4) + if scan_relevant: + time.sleep(4) return True + def _is_scan_module_relevant( + self, + module: str, + source_commit: str, + target_commit: str, + source_last_scanned_commit: str, + target_last_scanned_commit: str, + ): + """Determine if scanning the module is relevant. + + As the scan of a module can be quite time consuming, we first check + the files impacted among all new commits since the last scan. + If the all files are irrelevants, then we can bypass the scan. + """ + # The first time we want to scan the module obviously + if not source_last_scanned_commit: + return True + # Module still not available on target branch, no need to re-run a scan + # as it is still "To migrate" in this case + if not target_commit: + return False + # Module is available on target branch but it wasn't during the last scan + if not target_last_scanned_commit: + return True + # Other cases: check files impacted by new commits both on source & target + # branches to tell if a scan should be processed + repo = self.repo + source_tree = self._get_subtree(repo.commit(source_commit).tree, module) + target_tree = self._get_subtree(repo.commit(target_commit).tree, module) + source_new_commits = self._get_commits_of_git_tree( + source_last_scanned_commit, source_commit, source_tree + ) + source_to_scan = self._check_relevant_commits(module, source_new_commits) + target_new_commits = self._get_commits_of_git_tree( + target_last_scanned_commit, target_commit, target_tree + ) + target_to_scan = self._check_relevant_commits(module, target_new_commits) + return source_to_scan or target_to_scan + + def _check_relevant_commits(self, module, commits): + repo = self.repo + paths = set() + for commit_sha in commits: + commit = repo.commit(commit_sha) + if commit.parents: + diffs = commit.diff(commit.parents[0], paths=[module], R=True) + else: + diffs = commit.diff(git.NULL_TREE) + for diff in diffs: + paths.add(diff.a_path) + paths.add(diff.b_path) + for path in paths: + if all(not path.endswith(pattern) for pattern in IGNORE_FILES): + return True + return False + def _run_oca_port(self, module, source_branch, target_branch): _logger.info( "%s: collect migration data for '%s' (%s -> %s)", From a87a0c0f575086546bf930282ce1777e718a80fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 19 Nov 2023 19:14:40 +0100 Subject: [PATCH 011/160] MigrationScanner: no need to fetch branches As the RepositoryScanner (executed before MigrationScanner) has already fetched the branches of the repository, we avoid to fetch them again so the MigrationScanner will work on the local history of commits. This allows to speed up the processing of related jobs. Without new commits in repositories, a scan of all Odoo + OCA repositories now takes about ~6min instead of ~13min. --- odoo_repository/lib/scanner.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index f237e5b7..6cfd107f 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -48,11 +48,12 @@ def __init__( self.ssh_key = ssh_key self.github_token = github_token - def scan(self): + def scan(self, fetch=True): # Clone or update the repository if not self.is_cloned: self._clone() - self._fetch() + if fetch: + self._fetch() @contextlib.contextmanager def _get_git_env(self): @@ -273,11 +274,9 @@ def __init__( self.migration_paths = migration_paths def scan(self): - res = super().scan() - repo_id = self._get_odoo_repository_id() - # Get the repository branches from Odoo as the ones we got as parameter - # could not exist in the repository - self._get_odoo_repository_branches(repo_id) + # Clone/fetch has been done during the repository scan, the migration + # scan will be processed on the current history of commits + res = super().scan(fetch=False) for source_branch, target_branch in self.migration_paths: if self._branch_exists(source_branch) and self._branch_exists( target_branch From 2d557127628df459b4418a8b9d4e26449b0a33c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 22 Nov 2023 13:29:27 +0100 Subject: [PATCH 012/160] odoo_repository_migration: fix sync data from main node --- odoo_repository/models/odoo_repository.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 5f15181f..caa09b9c 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -279,7 +279,7 @@ def _import_data(self, data): for module_data in data: # TODO Move these methods to 'odoo.module.branch'? values = self._prepare_module_branch_values(module_data) - self._create_or_update_module_branch(values) + self._create_or_update_module_branch(values, module_data) def _prepare_module_branch_values(self, data): # Get branch, repository and technical module @@ -339,7 +339,7 @@ def _prepare_module_branch_values(self, data): } return values - def _create_or_update_module_branch(self, values): + def _create_or_update_module_branch(self, values, raw_data): mb_model = self.env["odoo.module.branch"] rec = mb_model.search( [ @@ -352,12 +352,21 @@ def _create_or_update_module_branch(self, values): ], limit=1, ) + values = self._pre_create_or_update_module_branch(rec, values, raw_data) if rec: rec.sudo().write(values) else: rec = mb_model.sudo().create(values) + self._post_create_or_update_module_branch(rec, values, raw_data) return rec + def _pre_create_or_update_module_branch(self, rec, values, raw_data): + """Hook executed before the creation or update of `rec`. Return values.""" + return values + + def _post_create_or_update_module_branch(self, rec, values, raw_data): + """Hook executed after the creation or update of `rec`.""" + @tools.ormcache("name") def _get_repository_org(self, name): rec = self.env["odoo.repository.org"].search([("name", "=", name)], limit=1) From 5c0b4f141c670582a818c07ea0680c3a6c31c12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 23 Nov 2023 11:23:14 +0100 Subject: [PATCH 013/160] odoo_repository: remove ORM cache from '_get_module' --- odoo_repository/models/odoo_module_branch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index e2a88220..1bc5c5be 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -379,7 +379,6 @@ def _get_license_id(self, license_name): return rec.id return False - @tools.ormcache("name") def _get_module(self, name): module = self.env["odoo.module"].search([("name", "=", name)]) if not module: From e619295d49f211554e695ba7ea084d21389254f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 26 Nov 2023 11:39:32 +0100 Subject: [PATCH 014/160] odoo_repository: limit the scan/force-scan to selected branches --- odoo_repository/models/odoo_repository.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index caa09b9c..db1c445c 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -175,22 +175,29 @@ def action_scan(self, branches=None, force=False): if not branches: branches = self._get_odoo_branches_to_clone().mapped("name") if force: - self._reset_scanned_commits() + self._reset_scanned_commits(branches) # Scan repository branches sequentially as they need to be checked out # to perform the analysis jobs = self._create_jobs(branches) chain(*jobs).delay() return True - def _reset_scanned_commits(self): + def _reset_scanned_commits(self, branches=None): """Reset the scanned commits. This will make the next repository scan restarting from the beginning, and thus making it slower. """ self.ensure_one() - self.branch_ids.write({"last_scanned_commit": False}) - self.branch_ids.module_ids.sudo().write({"last_scanned_commit": False}) + if branches is None: + branches = [] + branches_ = ( + self.branch_ids.filtered(lambda br: br.branch_id.name in branches) + if branches + else self.branch_ids + ) + branches_.write({"last_scanned_commit": False}) + branches_.module_ids.sudo().write({"last_scanned_commit": False}) def _create_jobs(self, branches): self.ensure_one() From 23084da3e6118857b70fa26591d01eec61e99864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 30 Nov 2023 10:49:44 +0100 Subject: [PATCH 015/160] odoo_repository: limit main node data fetch to selected branches --- odoo_repository/models/odoo_repository.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index db1c445c..53324589 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -264,7 +264,10 @@ def cron_fetch_data(self, branches=None, force=False): ) if not main_node_url: return False - branches = self.env["odoo.branch"].search([("odoo_version", "=", True)]) + branch_domain = [("odoo_version", "=", True)] + if branches: + branch_domain.append(("name", "in", branches)) + branches = self.env["odoo.branch"].search(branch_domain) branch_names = ",".join(branches.mapped("name")) url = f"{main_node_url}?branches=%s" % branch_names try: From 82944c137b3caa47386486cac26663feebfeeb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 30 Nov 2023 11:14:46 +0100 Subject: [PATCH 016/160] odoo_repository: ability to scan a branch on demand --- odoo_repository/models/odoo_branch.py | 18 ++++++++++ odoo_repository/models/odoo_repository.py | 26 +++++++------- .../models/odoo_repository_branch.py | 2 -- odoo_repository/views/odoo_branch.xml | 34 ++++++++++++++++++- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 08e385a2..a835ec0d 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -12,7 +12,25 @@ class OdooBranch(models.Model): name = fields.Char(required=True, index=True) odoo_version = fields.Boolean(default=True) active = fields.Boolean(default=True) + repository_branch_ids = fields.One2many( + comodel_name="odoo.repository.branch", + inverse_name="branch_id", + string="Repositories", + readonly=True, + ) _sql_constraints = [ ("name_uniq", "UNIQUE (name)", "This branch already exists."), ] + + def action_scan(self, force=False): + """Scan this branch in all repositories.""" + self.repository_branch_ids.action_scan(force=force) + + def action_force_scan(self): + """Force the scan of this branch in all repositories. + + It will restart the scan without considering the last scanned commit, + overriding already collected module data if any. + """ + return self.action_scan(force=True) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 53324589..f6a2d243 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -166,20 +166,20 @@ def _check_config(self): def action_scan(self, branches=None, force=False): """Scan the whole repository.""" - self.ensure_one() - if not self.to_scan: - return False self._check_config() - if self.clone_branch_id: - branches = [self.clone_branch_id.name] - if not branches: - branches = self._get_odoo_branches_to_clone().mapped("name") - if force: - self._reset_scanned_commits(branches) - # Scan repository branches sequentially as they need to be checked out - # to perform the analysis - jobs = self._create_jobs(branches) - chain(*jobs).delay() + for rec in self: + if not rec.to_scan: + return False + if rec.clone_branch_id: + branches = [rec.clone_branch_id.name] + if not branches: + branches = rec._get_odoo_branches_to_clone().mapped("name") + if force: + rec._reset_scanned_commits(branches) + # Scan repository branches sequentially as they need to be checked out + # to perform the analysis + jobs = rec._create_jobs(branches) + chain(*jobs).delay() return True def _reset_scanned_commits(self, branches=None): diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index 2bf728b1..5a8e45f4 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -48,7 +48,6 @@ def _compute_name(self): def action_scan(self, force=False): """Scan the repository/branch.""" - self.ensure_one() return self.repository_id.action_scan( branches=[self.branch_id.name], force=force ) @@ -59,5 +58,4 @@ def action_force_scan(self): It will restart the scan without considering the last scanned commit, overriding already collected module data if any. """ - self.ensure_one() return self.action_scan(force=True) diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index f2642183..203a3a69 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -3,11 +3,43 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> + + odoo.branch.form + odoo.branch + +
+
+
+ + + + + + +
+
+
+ odoo.branch.tree odoo.branch - + From 8805126d90f3a41a89d555b6847b5e3043380528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 30 Nov 2023 11:43:13 +0100 Subject: [PATCH 017/160] odoo_repository: improve branch form view --- odoo_repository/views/odoo_branch.xml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index 203a3a69..6f7ce5db 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -27,8 +27,23 @@ - - + + + + + + + + + + + From 6697c66633f4e0191db5a91e18aaa5df3bd2ab0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 26 Nov 2023 12:05:36 +0100 Subject: [PATCH 018/160] BaseScanner: fix '_get_commits_of_git_tree' to work without commit range --- odoo_repository/lib/scanner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 6cfd107f..3a1b7a80 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -223,9 +223,12 @@ def _get_last_commit_of_git_tree(self, ref, tree): return tree.repo.git.log("--pretty=%H", "-n 1", ref, "--", tree.path) def _get_commits_of_git_tree(self, from_, to_, tree): - commits = tree.repo.git.log( - "--pretty=%H", "-r", f"{from_}..{to_}", "--", tree.path - ) + rev_pattern = f"{from_}..{to_}" + if not from_: + rev_pattern = to_ + elif not to_: + rev_pattern = from_ + commits = tree.repo.git.log("--pretty=%H", "-r", rev_pattern, "--", tree.path) return commits.split() def _odoo_module(self, tree): From a9cf7e4f5f11dd4d385ddae21139408ee1715093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sat, 24 Feb 2024 14:43:23 +0100 Subject: [PATCH 019/160] BaseScanner: improve '_get_commits_of_git_tree' --- odoo_repository/lib/scanner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 3a1b7a80..f2f8360b 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -223,12 +223,18 @@ def _get_last_commit_of_git_tree(self, ref, tree): return tree.repo.git.log("--pretty=%H", "-n 1", ref, "--", tree.path) def _get_commits_of_git_tree(self, from_, to_, tree): + """Returns commits between `from_` and `to_` in chronological order. + + The list of commits can be limited to a `tree`. + """ rev_pattern = f"{from_}..{to_}" if not from_: rev_pattern = to_ elif not to_: rev_pattern = from_ - commits = tree.repo.git.log("--pretty=%H", "-r", rev_pattern, "--", tree.path) + commits = tree.repo.git.log( + "--pretty=%H", "-r", rev_pattern, "--reverse", "--", tree.path + ) return commits.split() def _odoo_module(self, tree): From 68767ceb85d8c069828095d374e939444415d3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 15 Apr 2024 16:17:41 +0200 Subject: [PATCH 020/160] odoo_repository: test lib.scanner.BaseScanner --- odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/common.py | 59 +++++++ odoo_repository/tests/test_base_scanner.py | 184 +++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 odoo_repository/tests/__init__.py create mode 100644 odoo_repository/tests/common.py create mode 100644 odoo_repository/tests/test_base_scanner.py diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py new file mode 100644 index 00000000..27580bea --- /dev/null +++ b/odoo_repository/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_scanner diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py new file mode 100644 index 00000000..af2fca30 --- /dev/null +++ b/odoo_repository/tests/common.py @@ -0,0 +1,59 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import pathlib +import re +import tempfile +from unittest.mock import patch + +import git +from oca_port.tests.common import CommonCase + +from odoo.tests.common import TransactionCase + + +class Common(TransactionCase, CommonCase): + 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] + + 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() + github_request.return_value = {} + self.addCleanup(self.patcher2.stop) + return res + + def _update_module_version_on_branch(self, branch, version): + """Change module version on a given branch, and commit the change.""" + repo = git.Repo(self.repo_upstream_path) + repo.git.checkout(branch) + # Update version in manifest file + lines = [] + with open(self.manifest_path, "r+") as manifest: + for line in manifest: + pattern = r".*version['\"]:\s['\"]([\d.]+).*" + match = re.search(pattern, line) + if match: + current_version = match.group(1) + line = line.replace(current_version, version) + lines.append(line) + with open(self.manifest_path, "r+") as manifest: + manifest.writelines(lines) + # Commit + repo.index.add(self.manifest_path) + commit = repo.index.commit( + f"[IMP] {self._settings['addon']}: bump version to {version}" + ) + return commit.hexsha + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.repositories_path = tempfile.mkdtemp() diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py new file mode 100644 index 00000000..cdd4a4c2 --- /dev/null +++ b/odoo_repository/tests/test_base_scanner.py @@ -0,0 +1,184 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import tempfile + +from odoo.addons.odoo_repository.lib.scanner import BaseScanner + +from .common import Common + + +class TestBaseScanner(Common): + def _init_scanner(self, **params): + kwargs = { + "org": self._settings["user_org"], + "name": self.repo_name, + "clone_url": self.repo_upstream_path, + "branches": [ + self._settings["branch1"], + self._settings["branch2"], + self._settings["branch3"], + ], + "repositories_path": self.repositories_path, + } + if params: + kwargs.update(params) + return BaseScanner(**kwargs) + + def test_init(self): + scanner = self._init_scanner() + self.assertTrue(scanner.repositories_path.exists()) + self.assertEqual(scanner.path.parts[-1], self.repo_name) + self.assertEqual(scanner.path.parts[-2], self._settings["user_org"]) + self.assertEqual( + scanner.full_name, f"{self._settings['user_org']}/{self.repo_name}" + ) + + def test_scan(self): + scanner = self._init_scanner(repositories_path=tempfile.mkdtemp()) + # Clone + self.assertFalse(scanner.path.exists()) + self.assertFalse(scanner.is_cloned) + scanner.scan() + self.assertTrue(scanner.is_cloned) + # Fetch once cloned + scanner.scan() + + def test_branch_exists(self): + scanner = self._init_scanner() + scanner.scan() + self.assertTrue(scanner._branch_exists(self._settings["branch1"])) + self.assertTrue(scanner._branch_exists(self._settings["branch2"])) + self.assertTrue(scanner._branch_exists(self._settings["branch3"])) + + def test_checkout_branch(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + branch_sha = repo.refs[f"origin/{branch}"].object.hexsha + self.assertNotEqual(repo.head.object.hexsha, branch_sha) + scanner._checkout_branch(branch) + self.assertEqual(repo.head.object.hexsha, branch_sha) + + def test_get_last_fetched_commit(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch1 = self._settings["branch1"] + branch2 = self._settings["branch2"] + branch3 = self._settings["branch3"] + branch1_sha = repo.refs[f"origin/{branch1}"].object.hexsha + branch2_sha = repo.refs[f"origin/{branch2}"].object.hexsha + branch3_sha = repo.refs[f"origin/{branch3}"].object.hexsha + self.assertEqual(scanner._get_last_fetched_commit(branch1), branch1_sha) + self.assertEqual(scanner._get_last_fetched_commit(branch2), branch2_sha) + self.assertEqual(scanner._get_last_fetched_commit(branch3), branch3_sha) + + def test_get_module_paths(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + module_paths = scanner._get_module_paths(".", branch) + self.assertEqual(len(module_paths), 1) + self.assertEqual(len(module_paths[0]), 2) + self.assertEqual(module_paths[0][0], self._settings["addon"]) + all_commits = [c.hexsha for c in repo.iter_commits(f"origin/{branch}")] + self.assertIn(module_paths[0][1], all_commits) + + def test_get_module_paths_updated(self): + scanner = self._init_scanner() + scanner.scan() + branch = self._settings["branch1"] + initial_commit = scanner._get_last_fetched_commit(branch) + # Case where from_commit and to_commit are the same: no change detected + module_paths = scanner._get_module_paths_updated( + relative_path=".", + from_commit=initial_commit, + to_commit=initial_commit, + branch=branch, + ) + self.assertFalse(module_paths) + # Update the upstream repository with a new commit + self._update_module_version_on_branch(branch, "1.0.1") + # Module is now detected has updated + scanner.scan() # Fetch new commit from upstream repo + last_commit = scanner._get_last_fetched_commit(branch) + module_paths = scanner._get_module_paths_updated( + relative_path=".", + from_commit=initial_commit, + to_commit=last_commit, + branch=branch, + ) + self.assertEqual(len(module_paths), 1) + module_path = module_paths.pop() + self.assertEqual(module_path[0], self._settings["addon"]) + self.assertEqual(module_path[1], last_commit) + + def test_filter_file_path(self): + scanner = self._init_scanner() + self.assertFalse(scanner._filter_file_path("fr.po")) + self.assertTrue(scanner._filter_file_path("test.py")) + + def test_get_last_commit_of_git_tree(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] + commit = scanner._get_last_commit_of_git_tree(remote_branch, module_tree) + self.assertIn(commit, all_commits) + + def test_get_commits_of_git_tree(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] + commits = scanner._get_commits_of_git_tree( + from_=None, to_=remote_branch, tree=module_tree + ) + for commit in commits: + self.assertIn(commit, all_commits) + + def test_odoo_module(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + self.assertTrue(scanner._odoo_module(module_tree)) + + def test_manifest_exists(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + # Check module tree: OK + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + self.assertTrue(scanner._manifest_exists(module_tree)) + # Check repository root tree: KO + self.assertFalse(scanner._manifest_exists(repo.tree(remote_branch))) + + def test_get_subtree(self): + scanner = self._init_scanner() + scanner.scan() + repo = scanner.repo + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + # Module/folder exists: OK + self.assertTrue(scanner._get_subtree(repo.tree(remote_branch), module)) + # Module/folder doesn't exist: KO + self.assertFalse(scanner._get_subtree(repo.tree(remote_branch), "none")) From 013da1cadb4d0072600e3dea859870b1b089f677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sat, 24 Feb 2024 14:50:12 +0100 Subject: [PATCH 021/160] odoo_repository: automatically sort 'odoo.branch' records which are Odoo versions --- odoo_repository/models/odoo_branch.py | 44 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index a835ec0d..2d9c3ae3 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -1,13 +1,13 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import fields, models +from odoo import api, fields, models class OdooBranch(models.Model): _name = "odoo.branch" _description = "Odoo Branch" - _order = "name" + _order = "sequence, name" name = fields.Char(required=True, index=True) odoo_version = fields.Boolean(default=True) @@ -18,11 +18,51 @@ class OdooBranch(models.Model): string="Repositories", readonly=True, ) + sequence = fields.Integer() _sql_constraints = [ ("name_uniq", "UNIQUE (name)", "This branch already exists."), ] + def _recompute_sequence(self): + """Recompute the 'sequence' field to get release branches sorted.""" + self.flush_recordset() + odoo_versions_to_recompute = self.search([("odoo_version", "=", True)]) + for odoo_version in odoo_versions_to_recompute: + query = """ + UPDATE odoo_branch + SET sequence = ( + SELECT pos.position + FROM ( + SELECT + id, + row_number() OVER ( + ORDER BY string_to_array(name, '.')::int[] + ) AS position + FROM odoo_branch + WHERE odoo_version = true + ) as pos + WHERE pos.id = %(id)s + ) + WHERE id = %(id)s; + """ + args = { + "id": odoo_version.id, + } + self.env.cr.execute(query, args) + self.invalidate_recordset(["sequence"]) + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res._recompute_sequence() + return res + + def write(self, values): + res = super().write(values) + self._recompute_sequence() + return res + def action_scan(self, force=False): """Scan this branch in all repositories.""" self.repository_branch_ids.action_scan(force=force) From e1df78c82455aba56b944104a2a625a54458b271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 8 Apr 2024 11:32:53 +0200 Subject: [PATCH 022/160] odoo_repository: fix force scan of project/specific branches --- odoo_repository/models/odoo_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index f6a2d243..f4f55434 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -193,7 +193,7 @@ def _reset_scanned_commits(self, branches=None): branches = [] branches_ = ( self.branch_ids.filtered(lambda br: br.branch_id.name in branches) - if branches + if branches and not self.clone_branch_id else self.branch_ids ) branches_.write({"last_scanned_commit": False}) From 4a563cd167f3f63482e436dd92a1cfe634b04904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 16 Apr 2024 11:07:33 +0200 Subject: [PATCH 023/160] odoo_repository: test lib.scanner.RepositoryScanner --- odoo_repository/lib/scanner.py | 5 + odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/common.py | 18 ++ .../tests/test_repository_scanner.py | 200 ++++++++++++++++++ odoo_repository/utils/scanner.py | 8 +- 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 odoo_repository/tests/test_repository_scanner.py diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index f2f8360b..025a011e 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -597,6 +597,7 @@ def _scan_addons_path( branch, ) # Scan each module + modules_scanned = {} for module_path, last_module_commit in module_paths: self._scan_module( branch, @@ -605,6 +606,9 @@ def _scan_addons_path( last_module_commit, addons_path_data, ) + module = module_path.split("/")[-1] + modules_scanned[module] = True + return modules_scanned def _scan_module( self, @@ -638,6 +642,7 @@ def _scan_module( # Set the last fetched commit as last scanned commit data["last_scanned_commit"] = last_module_commit self._push_scanned_data(repo_branch_id, module, data) + return data def _run_code_analysis(self, module_path): """Perform a code analysis of `module_path`.""" diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index 27580bea..9230c672 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -1 +1,2 @@ from . import test_base_scanner +from . import test_repository_scanner diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index af2fca30..dc05f8b0 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -19,6 +19,24 @@ def setUp(self): # 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._settings["user_org"]} + ) + self.odoo_repository = self.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, + "repo_type": "github", + } + ) + self.branch = self.env["odoo.branch"].create( + { + "name": self._settings["branch1"], + "odoo_version": True, + } + ) def _patch_github_class(self): res = super()._patch_github_class() diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py new file mode 100644 index 00000000..fc8b7b35 --- /dev/null +++ b/odoo_repository/tests/test_repository_scanner.py @@ -0,0 +1,200 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.addons.odoo_repository.utils.scanner import RepositoryScannerOdooEnv + +from .common import Common + + +class TestRepositoryScanner(Common): + def _init_scanner(self, **params): + kwargs = { + "org": self.org.name, + "name": self.repo_name, + "clone_url": self.repo_upstream_path, + "branches": [self.branch.name], + "addons_paths_data": [ + { + "relative_path": ".", + "is_standard": False, + "is_enterprise": False, + "is_community": True, + }, + ], + "repositories_path": self.repositories_path, + "env": self.env, + } + if params: + kwargs.update(params) + return RepositoryScannerOdooEnv(**kwargs) + + def test_init(self): + scanner = self._init_scanner() + self.assertTrue(scanner.repositories_path.exists()) + self.assertEqual(scanner.path.parts[-1], self.repo_name) + self.assertEqual(scanner.path.parts[-2], self._settings["user_org"]) + self.assertEqual( + scanner.full_name, f"{self._settings['user_org']}/{self.repo_name}" + ) + + def test_scan(self): + scanner = self._init_scanner() + scanner.scan() + + def test_get_odoo_repository_id(self): + scanner = self._init_scanner() + repo_id = scanner._get_odoo_repository_id() + self.assertEqual(repo_id, self.odoo_repository.id) + + def test_get_odoo_branch_id(self): + scanner = self._init_scanner() + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + self.assertEqual(branch_id, self.branch.id) + + def test_create_odoo_repository_branch(self): + scanner = self._init_scanner() + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + # The repository branch doesn't exist yet + expected_repo_branch_id = scanner._get_odoo_repository_branch_id( + repo_id, branch_id + ) + self.assertFalse(expected_repo_branch_id) + # Create it + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + self.assertTrue(repo_branch_id) + self.assertEqual( + repo_branch_id, scanner._get_odoo_repository_branch_id(repo_id, branch_id) + ) + + def test_get_repo_last_scanned_commit(self): + scanner = self._init_scanner() + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + # Nothing has been scanned until now + self.assertFalse(scanner._get_repo_last_scanned_commit(repo_branch_id)) + # Launch the scan and check again + scanner.scan() + last_fetched_commit = scanner._get_last_fetched_commit(self.branch.name) + last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) + self.assertEqual(last_fetched_commit, last_scanned_commit) + + def test_scan_addons_path(self): + scanner = self._init_scanner() + scanner._clone() + scanner._checkout_branch(self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + last_fetched_commit = scanner._get_last_fetched_commit(self.branch.name) + last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) + # Scan the addons_path (root of the repository here) + modules_scanned = scanner._scan_addons_path( + scanner.addons_paths_data[0], + self.branch.name, + repo_branch_id, + last_fetched_commit, + last_scanned_commit, + ) + module = self._settings["addon"] + self.assertIn(module, modules_scanned) + self.assertTrue(modules_scanned[module]) + + def test_scan_module(self): + scanner = self._init_scanner() + scanner._clone() + scanner._checkout_branch(self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + module_path = self._settings["addon"] + remote_branch = f"origin/{self.branch.name}" + module_tree = scanner.repo.tree(remote_branch) / module_path + last_module_commit = scanner._get_last_commit_of_git_tree( + remote_branch, module_tree + ) + # Scan module + addons_path_data = scanner.addons_paths_data[0] + data = scanner._scan_module( + self.branch.name, + repo_branch_id, + module_path, + last_module_commit, + addons_path_data, + ) + self.assertTrue(data) + self.assertTrue(data["code"]) + self.assertTrue(data["manifest"]) + self.assertEqual(data["is_standard"], addons_path_data["is_standard"]) + self.assertEqual(data["is_enterprise"], addons_path_data["is_enterprise"]) + self.assertEqual(data["is_community"], addons_path_data["is_community"]) + self.assertEqual(data["last_scanned_commit"], last_module_commit) + + def test_push_scanned_data(self): + scanner = self._init_scanner() + scanner._clone() + scanner._checkout_branch(self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + module = self._settings["addon"] + remote_branch = f"origin/{self.branch.name}" + module_tree = scanner.repo.tree(remote_branch) / module + last_module_commit = scanner._get_last_commit_of_git_tree( + remote_branch, module_tree + ) + addons_path_data = scanner.addons_paths_data[0] + data = scanner._scan_module( + self.branch.name, + repo_branch_id, + module, + last_module_commit, + addons_path_data, + ) + # Push scanned data + module_branch = scanner._push_scanned_data(repo_branch_id, module, data) + self.assertEqual(module_branch.module_id.name, module) + self.assertEqual(module_branch.repository_branch_id.id, repo_branch_id) + self.assertRecordValues( + module_branch, + [ + { + "repository_branch_id": repo_branch_id, + "is_standard": addons_path_data["is_standard"], + "is_enterprise": addons_path_data["is_enterprise"], + "is_community": addons_path_data["is_community"], + "application": data["manifest"].get("application", False), + "installable": data["manifest"]["installable"], + "sloc_python": data["code"]["Python"], + "sloc_xml": data["code"]["XML"], + "sloc_js": data["code"]["JavaScript"], + "sloc_css": data["code"]["CSS"], + "last_scanned_commit": last_module_commit, + } + ], + ) + + def test_update_last_scanned_commit(self): + scanner = self._init_scanner() + scanner._clone() + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) + last_repo_commit = scanner._get_last_fetched_commit(self.branch.name) + self.assertFalse(repo_branch.last_scanned_commit) + scanner._update_last_scanned_commit(repo_branch_id, last_repo_commit) + self.assertEqual(repo_branch.last_scanned_commit, last_repo_commit) + + def test_scan_branch(self): + scanner = self._init_scanner() + scanner._clone() + repo_id = scanner._get_odoo_repository_id() + # First scan: new commits detected + res = scanner._scan_branch(repo_id, self.branch.name) + self.assertTrue(res) + # Second scan: no new commits to scan + res = scanner._scan_branch(repo_id, self.branch.name) + self.assertFalse(res) diff --git a/odoo_repository/utils/scanner.py b/odoo_repository/utils/scanner.py index e119ab64..e20809aa 100644 --- a/odoo_repository/utils/scanner.py +++ b/odoo_repository/utils/scanner.py @@ -1,6 +1,8 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo.tools import config + from ..lib.scanner import RepositoryScanner @@ -71,7 +73,8 @@ def _push_scanned_data(self, repo_branch_id, module, data): repo_branch_id, module, data ) # Commit after each module - self.env.cr.commit() # pylint: disable=invalid-commit + if not config["test_enable"]: + self.env.cr.commit() # pylint: disable=invalid-commit return res def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): @@ -79,5 +82,6 @@ def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): repo_branch = repo_branch_model.browse(repo_branch_id) repo_branch.last_scanned_commit = last_fetched_commit # Commit after each repository/branch - self.env.cr.commit() # pylint: disable=invalid-commit + if not config["test_enable"]: + self.env.cr.commit() # pylint: disable=invalid-commit return True From 8a76485d68e94d449eeda74b7315febf02a61d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 17 Apr 2024 15:18:48 +0200 Subject: [PATCH 024/160] odoo_repository: raise error if no branches configured when scanning --- odoo_repository/models/odoo_repository.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index f4f55434..7012c7d6 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -174,6 +174,8 @@ def action_scan(self, branches=None, force=False): branches = [rec.clone_branch_id.name] if not branches: branches = rec._get_odoo_branches_to_clone().mapped("name") + if not branches: + raise UserError(_("No branches to scan.")) if force: rec._reset_scanned_commits(branches) # Scan repository branches sequentially as they need to be checked out From bf8bff8eda8c94197caf7f113cd2bda8e05f1262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 17 Apr 2024 18:19:18 +0200 Subject: [PATCH 025/160] odoo_repository: configure git if no config file exists Allows to run the tests locally in isolated environment like Docker. --- odoo_repository/tests/common.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index dc05f8b0..816d327b 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -1,6 +1,7 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import os import pathlib import re import tempfile @@ -38,6 +39,15 @@ def setUp(self): } ) + @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): res = super()._patch_github_class() # Patch helper method part of 'odoo_repository' module as well @@ -75,3 +85,4 @@ def setUpClass(cls): super().setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.repositories_path = tempfile.mkdtemp() + cls._apply_git_config() From b0c6a77b6a0fbe03d271640ad1993ddca8690e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 18 Apr 2024 10:15:32 +0200 Subject: [PATCH 026/160] BaseScanner: apply git configuration at repository level Instead of global level as the user running the scanner could not have write access to ~/.gitconfig. As such, the configuration is done after the cloning of repository, right before the fetch and later on the checkout (which is the operation that consumes most of the memory, the configuration is here to reduce it). Cloning operation itself is faster and consumes less memory with 'filter=blob:none' parameter. --- odoo_repository/lib/scanner.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 025a011e..e3fbe5ef 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -6,7 +6,6 @@ import logging import os import pathlib -import subprocess import tempfile import time @@ -44,7 +43,6 @@ def __init__( self.branches = branches self.repositories_path = self._prepare_repositories_path(repositories_path) self.path = self.repositories_path.joinpath(self.org, self.name) - self._apply_git_config() self.ssh_key = ssh_key self.github_token = github_token @@ -52,6 +50,7 @@ def scan(self, fetch=True): # Clone or update the repository if not self.is_cloned: self._clone() + self._apply_git_config() if fetch: self._fetch() @@ -93,9 +92,12 @@ def _prepare_repositories_path(self, repositories_path=None): def _apply_git_config(self): # This avoids too high memory consumption (default git config could # crash the Odoo workers when the scanner is run by Odoo itself). - subprocess.run(["git", "config", "--global", "core.packedGitLimit", "256m"]) - # self.repo.config_writer().set_value( - # "core", "packedGitLimit", "256m").release() + # This is especially useful to checkout big repositories like odoo/odoo. + with self.repo.config_writer() as writer: + writer.set_value("core", "packedGitLimit", "128m") + writer.set_value("core", "packedGitWindowSize", "32m") + writer.set_value("pack", "windowMemory", "64m") + writer.set_value("pack", "threads", "1") @property def is_cloned(self): @@ -112,7 +114,16 @@ def full_name(self): def _clone(self): _logger.info("Cloning %s...", self.full_name) with self._get_git_env() as git_env: - git.Repo.clone_from(self.clone_url, self.path, env=git_env) + # NOTE: adding 'no_checkout' and 'filter=blob:none' allows fast + # cloning and reduce memory usage. Blobs will be fetched later on + # demand, once the git config to reduce memory usage is applied. + git.Repo.clone_from( + self.clone_url, + self.path, + env=git_env, + no_checkout=True, + filter="blob:none", + ) def _fetch(self): repo = self.repo From 3fa6573e8b6e33009e16db404a54c4f49b7f24f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 19 Apr 2024 16:51:18 +0200 Subject: [PATCH 027/160] odoo_repository: test repository scan --- odoo_repository/tests/common.py | 20 ++++++--- .../tests/test_odoo_repository_scan.py | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 odoo_repository/tests/test_odoo_repository_scan.py diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 816d327b..59cfa2e0 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -32,12 +32,19 @@ def setUp(self): "repo_type": "github", } ) - self.branch = self.env["odoo.branch"].create( - { - "name": self._settings["branch1"], - "odoo_version": True, - } + self.branch = ( + self.env["odoo.branch"] + .with_context(active_test=False) + .search([("name", "=", self._settings["branch1"])]) ) + if not self.branch: + self.branch = self.env["odoo.branch"].create( + { + "name": self._settings["branch1"], + "odoo_version": True, + } + ) + self.module_name = self._settings["addon"] @classmethod def _apply_git_config(cls): @@ -85,4 +92,7 @@ def setUpClass(cls): super().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( + "odoo_repository_storage_path", cls.repositories_path + ) cls._apply_git_config() diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py new file mode 100644 index 00000000..72a4a54f --- /dev/null +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -0,0 +1,43 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestOdooRepositoryScan(Common): + def test_check_config(self): + self.odoo_repository._check_config() + + def test_action_scan(self): + module = self.env["odoo.module"].search([("name", "=", self.module_name)]) + self.assertFalse(module) + self.odoo_repository.with_context(test_queue_job_no_delay=True).action_scan( + [self.branch.name] + ) + # Check module technical name + module = self.env["odoo.module"].search([("name", "=", self.module_name)]) + self.assertTrue(module) + # Check module branch + module_branch = self.env["odoo.module.branch"].search( + [("module_id", "=", module.id), ("branch_id", "=", self.branch.id)] + ) + self.assertEqual(module_branch.module_name, self.module_name) + self.assertTrue(module_branch.last_scanned_commit) + self.assertEqual(module_branch.repository_id, self.odoo_repository) + self.assertEqual(module_branch.org_id, self.org) + self.assertEqual(module_branch.title, "Test") + self.assertEqual(module_branch.category_id.name, "Test Module") + self.assertItemsEqual( + module_branch.author_ids.mapped("name"), + ["Odoo Community Association (OCA)", "Camptocamp"], + ) + self.assertEqual(module_branch.dependency_ids.module_name, "base") + self.assertEqual(module_branch.license_id.name, "AGPL-3") + self.assertEqual(module_branch.version, "1.0.0") + self.assertTrue(module_branch.sloc_python) + # Check repository branch + repo_branch = module_branch.repository_branch_id + self.assertEqual(repo_branch.branch_id, self.branch) + self.assertEqual( + repo_branch.last_scanned_commit, module_branch.last_scanned_commit + ) From 265a049d07d8d6ecd6a52e25440b94a3a63da3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 19 Apr 2024 13:24:38 +0200 Subject: [PATCH 028/160] BaseScanner: remove hardcoded SSH key path --- odoo_repository/lib/scanner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index e3fbe5ef..fc47bcac 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -60,7 +60,6 @@ def _get_git_env(self): git_env = {} if self.ssh_key: with self._get_ssh_key() as ssh_key_path: - ssh_key_path = "/home/salix/.ssh/testing" # FIXME test git_ssh_cmd = f"ssh -o StrictHostKeyChecking=no -i {ssh_key_path}" git_env.update(GIT_SSH_COMMAND=git_ssh_cmd, GIT_TRACE="true") yield git_env From c76cbf9fea7a7b37092a7be463957508890f42d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 19 Apr 2024 13:25:43 +0200 Subject: [PATCH 029/160] odoo_repository: always link scanned module to highest priority repository --- odoo_repository/models/odoo_module_branch.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 1bc5c5be..124f8ea8 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -266,12 +266,29 @@ def push_scanned_data(self, repo_branch_id, module, data): return self._create_or_update(repo_branch, module, values) def _create_or_update(self, repo_branch, module, values): + # Check if the module was already scanned. + # We take care of checking the priority of repositories so any module + # tied to a wrong repository by mistake (due to a PR title mentionning + # it for instance while the original repo was still not scanned) will + # be attached to the repository with the highest priority. + # E.g: + # 1. we import a project using module A from odoo/odoo + # 2. odoo/odoo is still not scanned, but module A is anyway created + # 3. we find a PR in repo OCA/x mentionning it, module A is then + # attached to repo OCA/x + # 5. we scan odoo/odoo and find module A there, as odoo/odoo has a + # higher priority, it is replacing OCA/x as original repo of module A args = [ ("branch_id", "=", repo_branch.branch_id.id), ("module_id", "=", module.id), ] module_branch = self.search(args) if module_branch: + if ( + module_branch.repository_id.sequence + > repo_branch.repository_id.sequence + ): + values["repository_branch_id"] = repo_branch.id module_branch.sudo().write(values) else: module_branch = self.sudo().create(values) From 26c5f7f45adcff78af9eea4773e5f165b838a58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 26 Nov 2023 12:36:52 +0100 Subject: [PATCH 030/160] RepositoryScanner: push module versions to Odoo --- odoo_repository/lib/scanner.py | 95 ++++++++++++++++++- .../tests/test_repository_scanner.py | 3 + 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index fc47bcac..df3f7f72 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -1,11 +1,13 @@ # Copyright 2023 Camptocamp SA # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) +import ast import contextlib import json import logging import os import pathlib +import re import tempfile import time @@ -23,6 +25,8 @@ # update such files, the underlying module won't be scanned to preserve resources. IGNORE_FILES = [".po", ".pot", "README.rst", "index.html"] +MANIFEST_FILES = ("__manifest__.py", "__openerp__.py") + class BaseScanner: _dirname = "odoo-repositories" @@ -261,7 +265,7 @@ def _python_package(self, tree): def _manifest_exists(self, tree): """Check if the `git.Tree` object contains an Odoo manifest file.""" manifest_found = False - for manifest_file in ("__manifest__.py", "__openerp__.py"): + for manifest_file in MANIFEST_FILES: if self._get_subtree(tree, manifest_file): manifest_found = True break @@ -645,7 +649,9 @@ def _scan_module( branch, module_path, ) - data = self._run_code_analysis(module_path) + data = self._run_module_code_analysis( + module_path, branch, last_module_scanned_commit, last_module_commit + ) if data["manifest"]: # Insert all flags 'is_standard', 'is_enterprise', etc data.update(addons_path_data) @@ -654,10 +660,91 @@ def _scan_module( self._push_scanned_data(repo_branch_id, module, data) return data - def _run_code_analysis(self, module_path): + def _run_module_code_analysis(self, module_path, branch, from_commit, to_commit): """Perform a code analysis of `module_path`.""" + # Get current code analysis data module_analysis = ModuleAnalysis(f"{self.path}/{module_path}") - return module_analysis.to_dict() + data = module_analysis.to_dict() + # Append the history of versions + versions = self._read_module_versions( + module_path, branch, from_commit, to_commit + ) + data["versions"] = versions + return data + + def _read_module_versions(self, module_path, branch, from_commit, to_commit): + """Return versions data introduced between `from_commit` and `to_commit`.""" + versions = {} + repo = self.repo + for manifest_file in MANIFEST_FILES: + manifest_path = "/".join([module_path, manifest_file]) + manifest_tree = self._get_subtree( + repo.commit(to_commit).tree, manifest_path + ) + if not manifest_tree: + continue + new_commits = self._get_commits_of_git_tree( + from_commit, to_commit, manifest_tree + ) + versions_ = self._parse_module_versions_from_commits( + module_path, manifest_path, branch, new_commits + ) + versions.update(versions_) + return versions + + def _parse_module_versions_from_commits( + self, module_path, manifest_path, branch, new_commits + ): + """Parse module versions introduced in `new_commits`.""" + versions = {} + repo = self.repo + for commit_sha in new_commits: + commit = repo.commit(commit_sha) + if commit.parents: + diffs = commit.diff(commit.parents[0], R=True) + else: + diffs = commit.diff(git.NULL_TREE) + for diff in diffs: + # Check only diffs that update the manifest file + diff_manifest = diff.a_path.endswith( + manifest_path + ) or diff.b_path.endswith(manifest_path) + if not diff_manifest: + continue + # Try to parse the manifest file + try: + manifest_a = ast.literal_eval( + diff.a_blob and diff.a_blob.data_stream.read().decode() or "{}" + ) + manifest_b = ast.literal_eval( + diff.b_blob and diff.b_blob.data_stream.read().decode() or "{}" + ) + except SyntaxError: + _logger.warning(f"Unable to parse {manifest_path} on {branch}") + continue + # Detect version change (added or updated) + if manifest_a.get("version") == manifest_b.get("version"): + continue + if not manifest_b.get("version"): + # Module has been removed? Skipping + continue + version = manifest_b["version"] + # Skip versions that contains special characters + # (often human errors fixed afterwards) + clean_version = re.sub(r"[^0-9\.]", "", version) + if clean_version != version: + continue + # Detect migration script and bind the version to the commit sha + migration_path = "/".join([module_path, "migrations", version]) + migration_tree = self._get_subtree( + repo.tree(f"origin/{branch}"), migration_path + ) + values = { + "commit": commit_sha, + "migration_script": bool(migration_tree), + } + versions[version] = values + return versions # Hooks method to override by client class diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index fc8b7b35..ff452695 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -131,6 +131,9 @@ def test_scan_module(self): self.assertEqual(data["is_enterprise"], addons_path_data["is_enterprise"]) self.assertEqual(data["is_community"], addons_path_data["is_community"]) self.assertEqual(data["last_scanned_commit"], last_module_commit) + self.assertIn("1.0.0", data["versions"]) + self.assertEqual(data["versions"]["1.0.0"]["commit"], last_module_commit) + self.assertFalse(data["versions"]["1.0.0"]["migration_script"]) def test_push_scanned_data(self): scanner = self._init_scanner() From 6187f198e01b4d81b4844f276e7060168e47f3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 26 Nov 2023 11:46:24 +0100 Subject: [PATCH 031/160] odoo_repository: add module version data model --- odoo_repository/models/__init__.py | 1 + odoo_repository/models/odoo_module_branch.py | 7 +- .../models/odoo_module_branch_version.py | 84 +++++++++++++++++++ odoo_repository/models/odoo_repository.py | 5 ++ odoo_repository/security/ir.model.access.csv | 1 + odoo_repository/views/odoo_module_branch.xml | 9 ++ 6 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 odoo_repository/models/odoo_module_branch_version.py diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py index af6a0739..11b0876b 100644 --- a/odoo_repository/models/__init__.py +++ b/odoo_repository/models/__init__.py @@ -12,4 +12,5 @@ from . import odoo_repository from . import odoo_repository_branch from . import odoo_module_branch +from . import odoo_module_branch_version from . import res_config_settings diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 124f8ea8..dd7efd8f 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -117,7 +117,12 @@ class OdooModuleBranch(models.Model): string="License", index=True, ) - version = fields.Char() + version = fields.Char("Last version") + version_ids = fields.One2many( + comodel_name="odoo.module.branch.version", + inverse_name="module_branch_id", + string="Versions", + ) development_status_id = fields.Many2one( comodel_name="odoo.module.dev.status", ondelete="restrict", diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py new file mode 100644 index 00000000..7c31cb15 --- /dev/null +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -0,0 +1,84 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class OdooModuleBranchVersion(models.Model): + _name = "odoo.module.branch.version" + _description = "Version of a Odoo Module on a given branch" + _order = "sequence DESC" + + module_branch_id = fields.Many2one( + comodel_name="odoo.module.branch", + ondelete="cascade", + string="Module", + required=True, + index=True, + ) + name = fields.Char(required=True) + manifest_value = fields.Char( + required=True, help="Technical field to host the manifest value." + ) + commit = fields.Char() + has_migration_script = fields.Boolean(default=False) + migration_script_url = fields.Char( + string="Migration Script", + compute="_compute_migration_script_url", + ) + sequence = fields.Integer() + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res._recompute_sequence() + return res + + def write(self, values): + res = super().write(values) + self._recompute_sequence() + return res + + def _recompute_sequence(self): + """Recompute the 'sequence' field to get versions sorted.""" + self.flush_recordset() + versions_to_recompute = self.search( + [("module_branch_id", "in", self.module_branch_id.ids)] + ) + for version in versions_to_recompute: + query = """ + UPDATE odoo_module_branch_version + SET sequence = ( + SELECT pos.position + FROM ( + SELECT + id, + row_number() OVER ( + ORDER BY string_to_array(name, '.')::int[] + ) AS position + FROM odoo_module_branch_version + WHERE module_branch_id = %(module_branch_id)s + ) as pos + WHERE pos.id = %(id)s + ) + WHERE id = %(id)s; + """ + args = { + "module_branch_id": version.module_branch_id.id, + "id": version.id, + } + self.env.cr.execute(query, args) + self.invalidate_recordset(["sequence"]) + + @api.depends("name", "has_migration_script") + def _compute_migration_script_url(self): + for rec in self: + rec.migration_script_url = False + repo = rec.module_branch_id.repository_id + if rec.has_migration_script: + migration_path = "/".join( + [rec.module_branch_id.module_name, "migrations", rec.name] + ) + rec.migration_script_url = repo._get_resource_url( + rec.module_branch_id.branch_name, migration_path + ) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 7012c7d6..d2d96fe3 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -427,3 +427,8 @@ def _get_repository_branch(self, org_id, repository_id, branch_id, data): else: rec = self.env["odoo.repository.branch"].sudo().create(values) return rec + + def _get_resource_url(self, branch, path): + self.ensure_one() + # NOTE: GitHub and GitLab supports the same URL pattern + return "/".join([self.repo_url, "tree", branch, path]) diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index c16e1ebb..20c1bd99 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -19,3 +19,4 @@ access_odoo_repository_manager,odoo_repository_manager,model_odoo_repository,bas access_odoo_repository_branch_user,odoo_repository_branch_user,model_odoo_repository_branch,base.group_user,1,0,0,0 access_odoo_repository_branch_manager,odoo_repository_branch_manager,model_odoo_repository_branch,base.group_system,1,1,1,1 access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch,base.group_user,1,0,0,0 +access_odoo_module_branch_version_user,odoo_module_branch_version_user,model_odoo_module_branch_version,base.group_user,1,0,0,0 diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index 83f47943..cf4c9a53 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -47,6 +47,15 @@
+ + + + + + + + + From 888c543990ed19e16f6261002601c5861131e274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 26 Nov 2023 11:47:35 +0100 Subject: [PATCH 032/160] odoo_repository: import scanned module versions --- odoo_repository/__manifest__.py | 1 + odoo_repository/data/odoo_branch.xml | 87 ++++++++++++++++ odoo_repository/models/odoo_branch.py | 9 +- odoo_repository/models/odoo_module_branch.py | 99 ++++++++++++++++++- .../models/odoo_module_branch_version.py | 17 +++- odoo_repository/tests/__init__.py | 1 + .../tests/test_odoo_repository_scan.py | 6 ++ odoo_repository/tests/test_utils.py | 25 +++++ odoo_repository/utils/module.py | 25 +++++ 9 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 odoo_repository/data/odoo_branch.xml create mode 100644 odoo_repository/tests/test_utils.py create mode 100644 odoo_repository/utils/module.py diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 8236c522..565395ad 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -14,6 +14,7 @@ "data/odoo_repository_addons_path.xml", "data/odoo_repository.xml", "data/odoo.repository.csv", + "data/odoo_branch.xml", "data/queue_job.xml", "views/menu.xml", "views/ssh_key.xml", diff --git a/odoo_repository/data/odoo_branch.xml b/odoo_repository/data/odoo_branch.xml new file mode 100644 index 00000000..382a76a6 --- /dev/null +++ b/odoo_repository/data/odoo_branch.xml @@ -0,0 +1,87 @@ + + + + + + + + 8.0 + + + + + + 9.0 + + + + + + 10.0 + + + + + + 11.0 + + + + + + 12.0 + + + + + + 13.0 + + + + + + 14.0 + + + + + + 15.0 + + + + + + 16.0 + + + + + + 17.0 + + + + + + + + + + + + + + diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 2d9c3ae3..66b0f64d 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -24,10 +24,11 @@ class OdooBranch(models.Model): ("name_uniq", "UNIQUE (name)", "This branch already exists."), ] + @api.model def _recompute_sequence(self): """Recompute the 'sequence' field to get release branches sorted.""" self.flush_recordset() - odoo_versions_to_recompute = self.search([("odoo_version", "=", True)]) + odoo_versions_to_recompute = self._get_all_odoo_versions() for odoo_version in odoo_versions_to_recompute: query = """ UPDATE odoo_branch @@ -74,3 +75,9 @@ def action_force_scan(self): overriding already collected module data if any. """ return self.action_scan(force=True) + + def _get_all_odoo_versions(self): + """Return all Odoo versions, even archived ones.""" + return self.with_context(active_test=False).search( + [("odoo_version", "=", True)] + ) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index dd7efd8f..6b9ef8a4 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -10,6 +10,7 @@ from odoo.addons.queue_job.exception import RetryableJobError from ..utils import github +from ..utils.module import adapt_version class OdooModuleBranch(models.Model): @@ -222,9 +223,16 @@ def _find_repository_from_pr_url(self, pr_url): @api.returns("odoo.module.branch") def push_scanned_data(self, repo_branch_id, module, data): """Entry point for the scanner to push its data.""" - manifest = data["manifest"] module = self._get_module(module) repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) + values = self._prepare_module_branch_values(repo_branch, module, data) + return self._create_or_update(repo_branch, module, values) + + def _prepare_module_branch_values(self, repo_branch, module, data): + # Get existing module.branch if any + module_branch = self._get_module_branch(repo_branch, module) + # Prepare the 'odoo.module.branch' values + manifest = data["manifest"] category_id = self._get_module_category_id(manifest.get("category", "")) author_ids = self._get_author_ids(manifest.get("author", "")) maintainer_ids = self._get_maintainer_ids( @@ -268,7 +276,21 @@ def push_scanned_data(self, repo_branch_id, module, data): # Unset PR URL once the module is available in the repository. "pr_url": False, } - return self._create_or_update(repo_branch, module, values) + versions = self._prepare_module_branch_version_ids_values( + repo_branch, + module_branch, + module, + # If no history versions was scanned (could happen if versions are + # part of an unfetched branch), create one corresponding to the + # current manifest version if any but without commit. + versions=( + data.get("versions") + or ({values["version"]: {"commit": None}} if values["version"] else {}) + ), + ) + if versions: + values["version_ids"] = versions + return values def _create_or_update(self, repo_branch, module, values): # Check if the module was already scanned. @@ -299,6 +321,70 @@ def _create_or_update(self, repo_branch, module, values): module_branch = self.sudo().create(values) return module_branch + @api.model + def _get_existing_version(self, module, manifest_value, commit): + if not commit: + return self.env["odoo.module.branch.version"] + return self.env["odoo.module.branch.version"].search( + [ + ("module_name", "=", module.name), + ("manifest_value", "=", manifest_value), + ("commit", "=", commit), + ], + limit=1, + ) + + def _prepare_module_branch_version_ids_values( + self, repo_branch, module_branch, module, versions + ): + # Insert new versions + version_ids = [] + other_odoo_versions = ( + self.env["odoo.branch"]._get_all_odoo_versions() - repo_branch.branch_id + ) + for manifest_value, data in versions.items(): + # Version scanned doesn't belong to the current branch, skipping + if any( + manifest_value.startswith(odoo_version.name + ".") + for odoo_version in other_odoo_versions + ): + continue + name = adapt_version(repo_branch.branch_id.name, manifest_value) + # As we could import versions history from previous Odoo releases + # (i.e. the branch has been started from a previous one), check if + # it hasn't been imported already thanks to the related commit SHA + version = self._get_existing_version(module, manifest_value, data["commit"]) + if version and module_branch: + # Skip if the version has already been imported for a + # previous Odoo release + if version.branch_id.sequence < module_branch.branch_id.sequence: + continue + # Corner case: we scanned a version that was already imported + # through a newer Odoo branch. Downgrade the existing version + # to the current module branch. + if version.branch_id.sequence > module_branch.branch_id.sequence: + version.write( + { + "module_branch_id": module_branch.id, + "name": name, + } + ) + continue + module_version = module_branch.version_ids.filtered( + lambda v: v.name == name and v.manifest_value == manifest_value + ) + values = { + "name": name, + "manifest_value": manifest_value, + "commit": data["commit"], + "has_migration_script": data.get("migration_script", False), + } + if module_version: + version_ids.append(fields.Command.update(module_version.id, values)) + else: + version_ids.append(fields.Command.create(values)) + return version_ids + @tools.ormcache("category_name") def _get_module_category_id(self, category_name): if category_name: @@ -407,6 +493,15 @@ def _get_module(self, name): module = self.env["odoo.module"].sudo().create({"name": name}) return module + @api.model + def _get_module_branch(self, repo_branch, module): + """Return the `odoo.module.branch` if it already exists. Do not create it.""" + args = [ + ("branch_id", "=", repo_branch.branch_id.id), + ("module_id", "=", module.id), + ] + return self.search(args) + # TODO adds ormcache def _get_modules_data(self, orgs=None, repositories=None, branches=None): """Returns modules data matching the criteria. diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index 7c31cb15..fd52c354 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -7,7 +7,7 @@ class OdooModuleBranchVersion(models.Model): _name = "odoo.module.branch.version" _description = "Version of a Odoo Module on a given branch" - _order = "sequence DESC" + _order = "branch_sequence DESC, sequence DESC" module_branch_id = fields.Many2one( comodel_name="odoo.module.branch", @@ -16,6 +16,21 @@ class OdooModuleBranchVersion(models.Model): required=True, index=True, ) + branch_id = fields.Many2one( + related="module_branch_id.branch_id", + store=True, + ) + module_name = fields.Char( + string="Module Technical Name", + related="module_branch_id.module_name", + store=True, + index=True, + ) + branch_sequence = fields.Integer( + string="Branch Sequence", + related="branch_id.sequence", + store=True, + ) name = fields.Char(required=True) manifest_value = fields.Char( required=True, help="Technical field to host the manifest value." diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index 9230c672..266adba1 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -1,2 +1,3 @@ +from . import test_utils from . import test_base_scanner from . import test_repository_scanner diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 72a4a54f..1e1d7499 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -34,6 +34,12 @@ def test_action_scan(self): self.assertEqual(module_branch.dependency_ids.module_name, "base") self.assertEqual(module_branch.license_id.name, "AGPL-3") self.assertEqual(module_branch.version, "1.0.0") + self.assertEqual(module_branch.version_ids.manifest_value, "1.0.0") + self.assertEqual(module_branch.version_ids.name, f"{self.branch.name}.1.0.0") + self.assertEqual( + module_branch.version_ids.commit, module_branch.last_scanned_commit + ) + self.assertFalse(module_branch.version_ids.has_migration_script) self.assertTrue(module_branch.sloc_python) # Check repository branch repo_branch = module_branch.repository_branch_id diff --git a/odoo_repository/tests/test_utils.py b/odoo_repository/tests/test_utils.py new file mode 100644 index 00000000..612ea72f --- /dev/null +++ b/odoo_repository/tests/test_utils.py @@ -0,0 +1,25 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests.common import TransactionCase + +from odoo.addons.odoo_repository.utils.module import adapt_version + + +class TestUtils(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + def test_adapt_version(self): + # Module version equals major version: add prefix + self.assertEqual(adapt_version("14.0", "14.0"), "14.0.14.0") + # Basic module version: add prefix + self.assertEqual(adapt_version("14.0", "1.0.0"), "14.0.1.0.0") + # Module version already prefixed with major version + self.assertEqual(adapt_version("14.0", "14.0.1.0.0"), "14.0.1.0.0") + # Dot chars added as prefix or suffix in the provided version + self.assertEqual(adapt_version("14.0", ".1.0.0"), "14.0.1.0.0") + self.assertEqual(adapt_version("14.0", "1.0.0."), "14.0.1.0.0") + self.assertEqual(adapt_version("14.0", "...1.0.0..."), "14.0.1.0.0") diff --git a/odoo_repository/utils/module.py b/odoo_repository/utils/module.py new file mode 100644 index 00000000..e622e074 --- /dev/null +++ b/odoo_repository/utils/module.py @@ -0,0 +1,25 @@ +# Copyright 2023 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + + +def adapt_version(major_version: str, module_version: str): + """Return the module version prefixed with major version. + + This function has been copied and adapted from upstream Odoo source code: + https://github.com/odoo/odoo/blob/16.0/odoo/modules/module.py#L527-L533 + """ + # Remove extra special characters (e.g. '14.0.' => '14.0') + # NOTE: we need to sanitize such versions in order to compute a + # sequence number helping the sort of module versions. + chars = ["."] + for char in chars: + while module_version.startswith(char): + module_version = module_version[1:] + while module_version.endswith(char): + module_version = module_version[:-1] + # Append major Odoo version as prefix if needed + if module_version == major_version or not module_version.startswith( + major_version + "." + ): + module_version = "%s.%s" % (major_version, module_version) + return module_version From 231be2871cf49c70f65ebc3d303ac8617c10b911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 18 Apr 2024 15:14:49 +0200 Subject: [PATCH 033/160] odoo_repository: compute module URL to ease its browsing in upstream repository And fix the computed URL of migration scripts. --- odoo_repository/models/odoo_module_branch.py | 17 +++++++++++++++++ .../models/odoo_module_branch_version.py | 7 ++++++- odoo_repository/models/odoo_repository.py | 4 +++- .../tests/test_odoo_repository_scan.py | 1 + odoo_repository/views/odoo_module_branch.xml | 5 +++++ 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 6b9ef8a4..76a4a58d 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -146,6 +146,10 @@ class OdooModuleBranch(models.Model): sloc_js = fields.Integer("JS", help="JavaScript source lines of code") sloc_css = fields.Integer("CSS", help="CSS source lines of code") last_scanned_commit = fields.Char() + addons_path = fields.Char( + help="Technical field. Where the module is located in the repository." + ) + url = fields.Char("URL", compute="_compute_url") _sql_constraints = [ ( @@ -155,6 +159,17 @@ class OdooModuleBranch(models.Model): ), ] + @api.depends("repository_id.repo_url", "branch_name", "addons_path", "module_name") + def _compute_url(self): + for rec in self: + rec.url = False + if not rec.repository_id: + continue + module_path = "/".join([self.addons_path or ".", self.module_name]) + rec.url = self.repository_id._get_resource_url( + self.branch_name, module_path + ) + @api.depends("repository_branch_id.name", "module_id.name") def _compute_name(self): for rec in self: @@ -273,6 +288,7 @@ def _prepare_module_branch_values(self, repo_branch, module, data): "sloc_js": data["code"]["JavaScript"], "sloc_css": data["code"]["CSS"], "last_scanned_commit": data.get("last_scanned_commit", False), + "addons_path": data["relative_path"], # Unset PR URL once the module is available in the repository. "pr_url": False, } @@ -572,5 +588,6 @@ def _to_dict(self): "sloc_js": self.sloc_js, "sloc_css": self.sloc_css, "last_scanned_commit": self.last_scanned_commit, + "addons_path": self.addons_path, "pr_url": self.pr_url, } diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index fd52c354..07daab8a 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -92,7 +92,12 @@ def _compute_migration_script_url(self): repo = rec.module_branch_id.repository_id if rec.has_migration_script: migration_path = "/".join( - [rec.module_branch_id.module_name, "migrations", rec.name] + [ + rec.module_branch_id.addons_path or ".", + rec.module_branch_id.module_name, + "migrations", + rec.manifest_value, + ] ) rec.migration_script_url = repo._get_resource_url( rec.module_branch_id.branch_name, migration_path diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index d2d96fe3..d08e202f 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -4,6 +4,7 @@ import json import os import pathlib +from urllib.parse import urljoin import requests @@ -431,4 +432,5 @@ def _get_repository_branch(self, org_id, repository_id, branch_id, data): def _get_resource_url(self, branch, path): self.ensure_one() # NOTE: GitHub and GitLab supports the same URL pattern - return "/".join([self.repo_url, "tree", branch, path]) + url = "/".join(["tree", branch, path]) + return urljoin(self.repo_url + "/", url) diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 1e1d7499..8f143c00 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -41,6 +41,7 @@ def test_action_scan(self): ) self.assertFalse(module_branch.version_ids.has_migration_script) self.assertTrue(module_branch.sloc_python) + self.assertEqual(module_branch.addons_path, ".") # Check repository branch repo_branch = module_branch.repository_branch_id self.assertEqual(repo_branch.branch_id, self.branch) diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index cf4c9a53..40eb3093 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -33,6 +33,11 @@ widget="url" attrs="{'invisible': [('pr_url', '=', False)]}" /> + From effa0f5c93224e2a59b0800879b8aa7877adede8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 1 Dec 2023 09:39:04 +0100 Subject: [PATCH 034/160] odoo_repository: import module versions from main node --- odoo_repository/models/odoo_module_branch.py | 10 +--- .../models/odoo_module_branch_version.py | 11 ++++ odoo_repository/models/odoo_repository.py | 22 ++++++++ .../models/odoo_repository_branch.py | 13 +++++ odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/test_sync_node.py | 55 +++++++++++++++++++ 6 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 odoo_repository/tests/test_sync_node.py diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 76a4a58d..3bbb756b 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -559,14 +559,7 @@ def _to_dict(self): return { "module": self.module_name, "branch": self.branch_id.name, - "repository": { - "org": self.repository_id.org_id.name, - "name": self.repository_id.name, - "repo_url": self.repository_id.repo_url, - "repo_type": self.repository_id.repo_type, - "active": self.repository_id.active, - "last_scanned_commit": self.repository_branch_id.last_scanned_commit, - }, + "repository": self.repository_branch_id._to_dict(), "title": self.title, "summary": self.summary, "authors": self.author_ids.mapped("name"), @@ -575,6 +568,7 @@ def _to_dict(self): "category": self.category_id.name, "license": self.license_id.name, "version": self.version, + "versions": [version._to_dict() for version in self.version_ids], "development_status": self.development_status_id.name, "application": self.application, "installable": self.installable, diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index 07daab8a..3bc4e86d 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -102,3 +102,14 @@ def _compute_migration_script_url(self): rec.migration_script_url = repo._get_resource_url( rec.module_branch_id.branch_name, migration_path ) + + def _to_dict(self): + """Convert version data to a dictionary.""" + self.ensure_one() + return { + "name": self.name, + "manifest_value": self.manifest_value, + "commit": self.commit, + "has_migration_script": self.has_migration_script, + "sequence": self.sequence, + } diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index d08e202f..180bb3be 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -322,6 +322,9 @@ def _prepare_module_branch_values(self, data): tuple(external_dependencies.get("python", [])) ) license_id = mb_model._get_license_id(data["license"]) + versions_values = self._prepare_version_ids_values( + repository_branch, module, data["versions"] + ) values = { "repository_branch_id": repository_branch.id, "branch_id": repository_branch.branch_id.id, @@ -336,6 +339,7 @@ def _prepare_module_branch_values(self, data): "python_dependency_ids": [(6, 0, python_dependency_ids)], "license_id": license_id, "version": data["version"], + "version_ids": versions_values, "development_status_id": dev_status_id, "installable": data["installable"], "auto_install": data["auto_install"], @@ -352,6 +356,24 @@ def _prepare_module_branch_values(self, data): } return values + def _prepare_version_ids_values(self, repo_branch, module, versions: list[dict]): + version_ids = [] + for version in versions: + version_model = self.env["odoo.module.branch.version"] + rec = version_model.search( + [ + ("module_branch_id.branch_id", "=", repo_branch.branch_id.id), + ("module_branch_id.module_id", "=", module.id), + ("name", "=", version["name"]), + ], + limit=1, + ) + if rec: + version_ids.append(fields.Command.update(rec.id, version)) + else: + version_ids.append(fields.Command.create(version)) + return version_ids + def _create_or_update_module_branch(self, values, raw_data): mb_model = self.env["odoo.module.branch"] rec = mb_model.search( diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index 5a8e45f4..abe91456 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -59,3 +59,16 @@ def action_force_scan(self): overriding already collected module data if any. """ return self.action_scan(force=True) + + def _to_dict(self): + """Convert branch repository data to a dictionary.""" + self.ensure_one() + return { + "org": self.repository_id.org_id.name, + "name": self.repository_id.name, + "repo_url": self.repository_id.repo_url, + "repo_type": self.repository_id.repo_type, + "active": self.repository_id.active, + "branch": self.branch_id.name, + "last_scanned_commit": self.last_scanned_commit, + } diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index 266adba1..50c9d566 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_utils from . import test_base_scanner from . import test_repository_scanner +from . import test_sync_node diff --git a/odoo_repository/tests/test_sync_node.py b/odoo_repository/tests/test_sync_node.py new file mode 100644 index 00000000..0de24edb --- /dev/null +++ b/odoo_repository/tests/test_sync_node.py @@ -0,0 +1,55 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestSyncNode(Common): + def test_sync_node(self): + # Scan a repository + self.odoo_repository.with_context(test_queue_job_no_delay=True).action_scan( + [self.branch.name] + ) + # Check data to sync + data = self.env["odoo.module.branch"]._get_modules_data() + self.assertEqual(len(data), 1) # 1 module scanned + module = data[0] + self.assertEqual(module["module"], self.module_name) + self.assertEqual(module["branch"], self.branch.name) + self.assertEqual(module["version"], "1.0.0") + self.assertTrue(module["versions"]) + self.assertEqual(module["addons_path"], ".") + self.assertEqual(module["repository"]["name"], self.odoo_repository.name) + self.assertEqual(module["repository"]["org"], self.odoo_repository.org_id.name) + self.assertEqual( + module["repository"]["repo_type"], self.odoo_repository.repo_type + ) + self.assertEqual( + module["repository"]["repo_url"], self.odoo_repository.repo_url + ) + # Sync these data with a node + # NOTE as we are using the same node to sync with in tests, we change + # the content of the data to sync to create a new module + data[0].update( + module="synced", + version="2.0.0", + branch=self._settings["branch2"], + ) + new_module = self.env["odoo.module"].search([("name", "=", "synced")]) + self.assertFalse(new_module) + self.env["odoo.repository"]._import_data(data) + nb_modules = self.env["odoo.module"].search_count([]) + self.assertTrue(nb_modules >= 2) + new_module = self.env["odoo.module.branch"].search( + [("module_name", "=", "synced")] + ) + self.assertTrue(new_module) + self.assertEqual(new_module.version, "2.0.0") + self.assertEqual(new_module.branch_id.name, self._settings["branch2"]) + # Existing module didn't changed + existing_module = self.env["odoo.module.branch"].search( + [("module_name", "=", self.module_name)] + ) + self.assertTrue(existing_module) + self.assertEqual(existing_module.version, "1.0.0") + self.assertEqual(existing_module.branch_id, self.branch) From 4b3569debeb2d975aee5101226a24cd99ca37f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 19 Apr 2024 18:26:13 +0200 Subject: [PATCH 035/160] odoo_project_migration: list migration scripts to consider for a given migration path --- odoo_repository/models/odoo_module_branch_version.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index 3bc4e86d..534ee988 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -20,6 +20,12 @@ class OdooModuleBranchVersion(models.Model): related="module_branch_id.branch_id", store=True, ) + module_id = fields.Many2one( + string="Module", + related="module_branch_id.module_id", + store=True, + index=True, + ) module_name = fields.Char( string="Module Technical Name", related="module_branch_id.module_name", From 9d36c574ef1ae610d08f8adcfe80f520682507fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 22 Apr 2024 10:16:30 +0200 Subject: [PATCH 036/160] BaseScanner: ensure to clean up local repository before checkout --- odoo_repository/lib/scanner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index df3f7f72..80bf740a 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -151,6 +151,9 @@ def _branch_exists(self, branch): return branch in refs def _checkout_branch(self, branch): + # Ensure to clean up the repository before a checkout + self.repo.git.reset("--hard") + self.repo.git.clean("-xdf") self.repo.refs[f"origin/{branch}"].checkout() def _get_last_fetched_commit(self, branch): From a9142556ca1ce86fcf8ee3feb309fb1fbff8884d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 24 Apr 2024 09:25:57 +0200 Subject: [PATCH 037/160] odoo_repository: auto-archive odoo.repository.branch records If the repository or the branch is archived, archive all related 'odoo.repository.branch' records. --- odoo_repository/models/odoo_repository_branch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index abe91456..755cc7f1 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -32,6 +32,7 @@ class OdooRepositoryBranch(models.Model): readonly=True, ) last_scanned_commit = fields.Char(readonly=True) + active = fields.Boolean(compute="_compute_active", store=True) _sql_constraints = [ ( @@ -46,6 +47,11 @@ def _compute_name(self): for rec in self: rec.name = f"{rec.repository_id.display_name}#{rec.branch_id.name}" + @api.depends("repository_id.active", "branch_id.active") + def _compute_active(self): + for rec in self: + rec.active = all((rec.repository_id.active, rec.branch_id.active)) + def action_scan(self, force=False): """Scan the repository/branch.""" return self.repository_id.action_scan( From 37d24495c5122fdfb7fcc860f880a9fd53e4cca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 25 Apr 2024 09:10:50 +0200 Subject: [PATCH 038/160] odoo_repository: fix 'odoo.repository.action_scan' to not override 'branches' parameter --- odoo_repository/models/odoo_repository.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 180bb3be..b5265c3d 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -169,19 +169,23 @@ def action_scan(self, branches=None, force=False): """Scan the whole repository.""" self._check_config() for rec in self: + # Copy `branches` list to not override initial values + branches_ = branches and branches[:] or [] if not rec.to_scan: return False if rec.clone_branch_id: - branches = [rec.clone_branch_id.name] - if not branches: - branches = rec._get_odoo_branches_to_clone().mapped("name") - if not branches: + # Repository qualified with e.g. '17.0' branch but cloning a + # different branch like 'main' + branches_ = [rec.clone_branch_id.name] + if not branches_: + branches_ = rec._get_odoo_branches_to_clone().mapped("name") + if not branches_: raise UserError(_("No branches to scan.")) if force: - rec._reset_scanned_commits(branches) + rec._reset_scanned_commits(branches_) # Scan repository branches sequentially as they need to be checked out # to perform the analysis - jobs = rec._create_jobs(branches) + jobs = rec._create_jobs(branches_) chain(*jobs).delay() return True From f2baf937b89a8700dbf21e465d018488ab145db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 25 Apr 2024 13:22:05 +0200 Subject: [PATCH 039/160] MigrationScanner: store 'oca_port' cache in repositories data folder This will improve the performance of further migration scans. --- odoo_repository/lib/scanner.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 80bf740a..3ef36147 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -28,6 +28,33 @@ MANIFEST_FILES = ("__manifest__.py", "__openerp__.py") +@contextlib.contextmanager +def set_env(**environ): + """ + Temporarily set the process environment variables. + + >>> with set_env(PLUGINS_DIR='test/plugins'): + ... "PLUGINS_DIR" in os.environ + True + + >>> "PLUGINS_DIR" in os.environ + False + + :type environ: dict[str, unicode] + :param environ: Environment variables to set + """ + # Copied from: + # https://stackoverflow.com/questions/2059482/ + # temporarily-modify-the-current-processs-environment + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + class BaseScanner: _dirname = "odoo-repositories" @@ -484,7 +511,10 @@ def _run_oca_port(self, module, source_branch, target_branch): "fetch": False, "github_token": self.github_token, } - scan = oca_port.App(**params) + # Store oca_port cache in the same folder than cloned repositories + # to boost performance of further calls + with set_env(XDG_CACHE_HOME=str(self.repositories_path)): + scan = oca_port.App(**params) try: json_data = scan.run() except ValueError as exc: From 46ba42e7b8aae4ea6f3fc7b481f69a8836d61f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 2 May 2024 15:15:43 +0200 Subject: [PATCH 040/160] odoo_repository: handle git authentication with tokens --- odoo_repository/__manifest__.py | 1 + odoo_repository/lib/scanner.py | 45 +++++++++++---- odoo_repository/models/__init__.py | 2 + .../models/authentication_token.py | 12 ++++ odoo_repository/models/odoo_repository.py | 27 +++++++-- odoo_repository/models/res_company.py | 14 +++++ odoo_repository/models/res_config_settings.py | 5 +- odoo_repository/security/ir.model.access.csv | 2 + odoo_repository/tests/test_base_scanner.py | 13 +++++ .../views/authentication_token.xml | 57 +++++++++++++++++++ odoo_repository/views/odoo_repository.xml | 7 +++ odoo_repository/views/res_config_settings.xml | 12 ++-- 12 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 odoo_repository/models/authentication_token.py create mode 100644 odoo_repository/models/res_company.py create mode 100644 odoo_repository/views/authentication_token.xml diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 565395ad..5bcf0713 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -17,6 +17,7 @@ "data/odoo_branch.xml", "data/queue_job.xml", "views/menu.xml", + "views/authentication_token.xml", "views/ssh_key.xml", "views/odoo_author.xml", "views/odoo_branch.xml", diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 3ef36147..1432dc2b 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -10,6 +10,7 @@ import re import tempfile import time +from urllib.parse import urlparse, urlunparse import git import oca_port @@ -65,23 +66,26 @@ def __init__( clone_url: str, branches: list, repositories_path: str = None, + repo_type: str = None, ssh_key: str = None, - github_token: str = None, + token: str = None, ): self.org = org self.name = name - self.clone_url = clone_url + self.clone_url = self._prepare_clone_url(repo_type, clone_url, token) self.branches = branches self.repositories_path = self._prepare_repositories_path(repositories_path) self.path = self.repositories_path.joinpath(self.org, self.name) + self.repo_type = repo_type self.ssh_key = ssh_key - self.github_token = github_token + self.token = token def scan(self, fetch=True): # Clone or update the repository if not self.is_cloned: self._clone() self._apply_git_config() + self._set_git_remote_url() if fetch: self._fetch() @@ -106,14 +110,29 @@ def _get_ssh_key(self): ssh_key_path = fp.name yield ssh_key_path - def _prepare_repositories_path(self, repositories_path=None): + @staticmethod + def _prepare_clone_url(repo_type, clone_url, token): + """Return the URL used to clone/fetch the repository. + + If a token is provided it will be inserted automatically. + """ + if repo_type in ("github", "gitlab") and token: + parts = list(urlparse(clone_url)) + if parts[0].startswith("http"): + # Update 'netloc' part to prefix it with the OAuth token + parts[1] = f"oauth2:{token}@" + parts[1] + clone_url = urlunparse(parts) + return clone_url + + @classmethod + def _prepare_repositories_path(cls, repositories_path=None): if not repositories_path: default_data_dir_path = ( pathlib.Path.home().joinpath(".local").joinpath("share") ) repositories_path = pathlib.Path( os.environ.get("XDG_DATA_HOME", default_data_dir_path), - self._dirname, + cls._dirname, ) repositories_path = pathlib.Path(repositories_path) repositories_path.mkdir(parents=True, exist_ok=True) @@ -129,6 +148,10 @@ def _apply_git_config(self): writer.set_value("pack", "windowMemory", "64m") writer.set_value("pack", "threads", "1") + def _set_git_remote_url(self): + """Ensure that 'origin' remote is set with the right URL.""" + self.repo.remotes["origin"].set_url(self.clone_url) + @property def is_cloned(self): return self.path.joinpath(".git").exists() @@ -317,12 +340,13 @@ def __init__( clone_url: str, migration_paths: list[tuple[str]], repositories_path: str = None, + repo_type: str = None, ssh_key: str = None, - github_token: str = None, + token: str = None, ): branches = sorted(set(sum([tuple(mp) for mp in migration_paths], ()))) super().__init__( - org, name, clone_url, branches, repositories_path, ssh_key, github_token + org, name, clone_url, branches, repositories_path, repo_type, ssh_key, token ) self.migration_paths = migration_paths @@ -509,7 +533,7 @@ def _run_oca_port(self, module, source_branch, target_branch): "repo_name": self.name, "output": "json", "fetch": False, - "github_token": self.github_token, + "github_token": self.repo_type == "github" and self.token or None, } # Store oca_port cache in the same folder than cloned repositories # to boost performance of further calls @@ -570,11 +594,12 @@ def __init__( branches: list, addons_paths_data: list, repositories_path: str = None, + repo_type: str = None, ssh_key: str = None, - github_token: str = None, + token: str = None, ): super().__init__( - org, name, clone_url, branches, repositories_path, ssh_key, github_token + org, name, clone_url, branches, repositories_path, repo_type, ssh_key, token ) self.addons_paths_data = addons_paths_data diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py index 11b0876b..e32af68e 100644 --- a/odoo_repository/models/__init__.py +++ b/odoo_repository/models/__init__.py @@ -1,3 +1,4 @@ +from . import authentication_token from . import ssh_key from . import odoo_author from . import odoo_branch @@ -13,4 +14,5 @@ from . import odoo_repository_branch from . import odoo_module_branch from . import odoo_module_branch_version +from . import res_company from . import res_config_settings diff --git a/odoo_repository/models/authentication_token.py b/odoo_repository/models/authentication_token.py new file mode 100644 index 00000000..754fa58b --- /dev/null +++ b/odoo_repository/models/authentication_token.py @@ -0,0 +1,12 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class AuthenticationToken(models.Model): + _name = "authentication.token" + _description = "Authentication Token" + + name = fields.Char(required=True) + token = fields.Char(required=True) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index b5265c3d..c7d9bb36 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -62,6 +62,12 @@ class OdooRepository(models.Model): string="SSH Key", help="SSH key used to clone/fetch this repository.", ) + token_id = fields.Many2one( + comodel_name="authentication.token", + ondelete="restrict", + string="Token", + help="Token used to clone/fetch this repository.", + ) clone_branch_id = fields.Many2one( comodel_name="odoo.branch", ondelete="restrict", @@ -227,12 +233,24 @@ def _scan_branch(self, branch): except Exception as exc: raise RetryableJobError("Scanner error") from exc + def _get_token(self): + """Return the first available token found for this repository. + + It will check the available tokens in this order: + - specific token linked to this repository + - default token defined in the global settings + - token defined through an environment variable + """ + self.ensure_one() + return ( + self.token_id.token + or self.env.company.config_odoo_repository_default_token_id.token + or os.environ.get("GITHUB_TOKEN") + ) + def _prepare_scanner_parameters(self, branch): ir_config = self.env["ir.config_parameter"] repositories_path = ir_config.get_param(self._repositories_path_key) - github_token = ir_config.get_param( - "odoo_repository_github_token", os.environ.get("GITHUB_TOKEN") - ) return { "org": self.org_id.name, "name": self.name, @@ -247,8 +265,9 @@ def _prepare_scanner_parameters(self, branch): ] ), "repositories_path": repositories_path, + "repo_type": self.repo_type, "ssh_key": self.ssh_key_id.private_key, - "github_token": github_token, + "token": self._get_token(), "env": self.env, } diff --git a/odoo_repository/models/res_company.py b/odoo_repository/models/res_company.py new file mode 100644 index 00000000..e7829822 --- /dev/null +++ b/odoo_repository/models/res_company.py @@ -0,0 +1,14 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + config_odoo_repository_default_token_id = fields.Many2one( + comodel_name="authentication.token", + string="Default token", + help="Default token used to clone repositories and authenticate on API like GitHub.", + ) diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py index 6eb02e4c..7e1f20d4 100644 --- a/odoo_repository/models/res_config_settings.py +++ b/odoo_repository/models/res_config_settings.py @@ -10,8 +10,9 @@ class ResConfigSettings(models.TransientModel): config_odoo_repository_storage_path = fields.Char( string="Storage local path", config_parameter="odoo_repository_storage_path" ) - config_odoo_repository_github_token = fields.Char( - string="GitHub Token", config_parameter="odoo_repository_github_token" + config_odoo_repository_default_token_id = fields.Many2one( + related="company_id.config_odoo_repository_default_token_id", + readonly=False, ) config_odoo_repository_main_node_url = fields.Char( string="Endpoint URL", config_parameter="odoo_repository_main_node_url" diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index 20c1bd99..4fb9fa04 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -1,4 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_authentication_token_user,authentication_token_user,model_authentication_token,base.group_user,0,0,0,0 +access_authentication_manager,authentication_token_manager,model_authentication_token,base.group_system,1,1,1,1 access_ssh_key_user,ssh_key_user,model_ssh_key,base.group_user,0,0,0,0 access_ssh_key_manager,ssh_key_manager,model_ssh_key,base.group_system,1,1,1,1 access_odoo_author_user,odoo_author_user,model_odoo_author,base.group_user,1,0,0,0 diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index cdd4a4c2..0c1e3de4 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -34,6 +34,19 @@ def test_init(self): scanner.full_name, f"{self._settings['user_org']}/{self.repo_name}" ) + def test_clone_url_github_token(self): + # Without token + base_clone_url = "https://github.com/OCA/test" + scanner = self._init_scanner(repo_type="github", clone_url=base_clone_url) + self.assertEqual(scanner.clone_url, base_clone_url) + # With a token + token = "test" + scanner = self._init_scanner( + repo_type="github", clone_url=base_clone_url, token=token + ) + token_clone_url = f"https://oauth2:{token}@github.com/OCA/test" + self.assertEqual(scanner.clone_url, token_clone_url) + def test_scan(self): scanner = self._init_scanner(repositories_path=tempfile.mkdtemp()) # Clone diff --git a/odoo_repository/views/authentication_token.xml b/odoo_repository/views/authentication_token.xml new file mode 100644 index 00000000..1afb5380 --- /dev/null +++ b/odoo_repository/views/authentication_token.xml @@ -0,0 +1,57 @@ + + + + + + authentication.token.form + authentication.token + + +
+ + + + + + +
+
+
+ + + authentication.token.tree + authentication.token + + + + + + + + + authentication.token.search + authentication.token + search + + + + + + + + + Authentication Tokens + ir.actions.act_window + authentication.token + + + + + + +
diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index aec63f43..6939ecd3 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -50,10 +50,17 @@ name="repo_type" attrs="{'readonly': [('branch_ids', '!=', [])]}" /> + diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 592b3d24..90fcb9ab 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -43,22 +43,22 @@ name="odoo_repository_github_token" >
- GitHub token (API) + Default Authentication Token
- GitHub token used to request the API. + Default token used to clone repositories and authenticate on API like GitHub.
- Another way is to define the GITHUB_TOKEN environment variable on your system. + If the environment variable GITHUB_TOKEN is set on the running system, it will be used as fallback.
From 8a970cebb1f99cbacb1e70cc8efeae3cc014b81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 10 May 2024 09:51:23 +0200 Subject: [PATCH 041/160] odoo_repository: handle blacklisted modules We do not want to scan some modules and attach them to a repository as while they have the same name among different projects, they do not share the same code and are specific to a project. Such modules are for instance: - studio_customization (generated by Studio in Odoo Enterprise) - server_environment_files (created manually by developers to leverage server_environment module features) --- odoo_repository/__manifest__.py | 1 + odoo_repository/data/odoo_module.xml | 16 ++++++++++++ odoo_repository/lib/scanner.py | 19 ++++++++++++++ odoo_repository/models/odoo_module.py | 4 +++ odoo_repository/security/ir.model.access.csv | 1 + odoo_repository/utils/scanner.py | 7 ++++++ odoo_repository/views/odoo_module.xml | 26 ++++++++++++++++++-- 7 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 odoo_repository/data/odoo_module.xml diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 5bcf0713..e80a15ec 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -10,6 +10,7 @@ "data": [ "security/ir.model.access.csv", "data/ir_cron.xml", + "data/odoo_module.xml", "data/odoo_repository_org.xml", "data/odoo_repository_addons_path.xml", "data/odoo_repository.xml", diff --git a/odoo_repository/data/odoo_module.xml b/odoo_repository/data/odoo_module.xml new file mode 100644 index 00000000..fdca6d0e --- /dev/null +++ b/odoo_repository/data/odoo_module.xml @@ -0,0 +1,16 @@ + + + + + + server_environment_files + + + + + studio_customization + + + + diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 1432dc2b..b9d388ac 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -367,6 +367,13 @@ def _scan_migration_path(self, source_branch, target_branch): repo_target_commit = self._get_last_fetched_commit(target_branch) modules = self._get_module_paths(".", source_branch) for module, __ in modules: + if self._is_module_blacklisted(module): + _logger.info( + "%s: '%s' is blacklisted (no migration scan)", + self.full_name, + module, + ) + continue module_branch_id = self._get_odoo_module_branch_id(module, source_branch) if not module_branch_id: _logger.warning( @@ -691,6 +698,14 @@ def _scan_module( addons_path_data, ): module = module_path.split("/")[-1] + if self._is_module_blacklisted(module): + _logger.info( + "%s#%s: '%s' is blacklisted (no scan)", + self.full_name, + branch, + module_path, + ) + return last_module_scanned_commit = self._get_module_last_scanned_commit( repo_branch_id, module ) @@ -831,6 +846,10 @@ def _get_repo_last_scanned_commit(self, repo_branch_id): """Return the last scanned commit of the repository/branch.""" raise NotImplementedError + def _is_module_blacklisted(self, module): + """Check if `module` is blacklisted (and should not be scanned).""" + raise NotImplementedError + def _get_module_last_scanned_commit(self, repo_branch_id, module): """Return the last scanned commit of the module.""" raise NotImplementedError diff --git a/odoo_repository/models/odoo_module.py b/odoo_repository/models/odoo_module.py index 7145ca3e..24c8ba9c 100644 --- a/odoo_repository/models/odoo_module.py +++ b/odoo_repository/models/odoo_module.py @@ -13,6 +13,10 @@ class OdooModule(models.Model): comodel_name="odoo.module.branch", inverse_name="module_id", string="Modules", + readonly=True, + ) + blacklisted = fields.Boolean( + help="Blacklisted modules won't be scanned.", readonly=True ) _sql_constraints = [ diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index 4fb9fa04..c2c85951 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -9,6 +9,7 @@ access_odoo_branch_manager,odoo_branch_manager,model_odoo_branch,base.group_syst access_odoo_license_user,odoo_license_user,model_odoo_license,base.group_user,1,0,0,0 access_odoo_maintainer_user,odoo_maintainer_user,model_odoo_maintainer,base.group_user,1,0,0,0 access_odoo_module_user,odoo_module_user,model_odoo_module,base.group_user,1,0,0,0 +access_odoo_module_manager,odoo_module_manager,model_odoo_module,base.group_system,1,0,1,1 access_odoo_module_category_user,odoo_module_category_user,model_odoo_module_category,base.group_user,1,0,0,0 access_odoo_module_dev_status_user,odoo_module_dev_status_user,model_odoo_module_dev_status,base.group_user,1,0,0,0 access_odoo_python_dependency_user,odoo_python_dependency_user,model_odoo_python_dependency,base.group_user,1,0,0,0 diff --git a/odoo_repository/utils/scanner.py b/odoo_repository/utils/scanner.py index e20809aa..5c66ef27 100644 --- a/odoo_repository/utils/scanner.py +++ b/odoo_repository/utils/scanner.py @@ -59,6 +59,13 @@ def _get_repo_last_scanned_commit(self, repo_branch_id): repo_branch = repo_branch_model.browse(repo_branch_id) return repo_branch.last_scanned_commit + def _is_module_blacklisted(self, module): + return bool( + self.env["odoo.module"].search_count( + [("name", "=", module), ("blacklisted", "=", True)] + ) + ) + def _get_module_last_scanned_commit(self, repo_branch_id, module_name): module_branch_model = self.env["odoo.module.branch"] args = [ diff --git a/odoo_repository/views/odoo_module.xml b/odoo_repository/views/odoo_module.xml index a75b4c0c..b7a2efd9 100644 --- a/odoo_repository/views/odoo_module.xml +++ b/odoo_repository/views/odoo_module.xml @@ -9,10 +9,25 @@
+ + - + @@ -37,6 +52,7 @@ + @@ -48,15 +64,21 @@ + - Module Technical Names + Technical Module Names ir.actions.act_window odoo.module + {"default_blacklisted": 1} Date: Fri, 10 May 2024 11:43:54 +0200 Subject: [PATCH 042/160] odoo_repository: add odoo/enterprise repository (archived by default) --- odoo_repository/data/odoo_repository.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml index f8c20f8e..98a07303 100644 --- a/odoo_repository/data/odoo_repository.xml +++ b/odoo_repository/data/odoo_repository.xml @@ -21,4 +21,22 @@ /> + + + enterprise + + + https://github.com/odoo/enterprise + https://github.com/odoo/enterprise + github + + + From 4163ce83a0825065dda02635ea6dd4b98aee62cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 10 May 2024 11:44:42 +0200 Subject: [PATCH 043/160] odoo_repository: provide common addons_path --- odoo_repository/data/odoo_repository_addons_path.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/odoo_repository/data/odoo_repository_addons_path.xml b/odoo_repository/data/odoo_repository_addons_path.xml index ac64b0c9..ee816486 100644 --- a/odoo_repository/data/odoo_repository_addons_path.xml +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -36,4 +36,16 @@ + + . + + + + + . + + From 0a2c04c41dae414fe7642df7bd803f20af877a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 23 May 2024 18:01:28 +0200 Subject: [PATCH 044/160] odoo_repository: handle module removed from a repository branch --- odoo_repository/lib/scanner.py | 62 ++++++++----- odoo_repository/models/odoo_module_branch.py | 96 ++++++++++++-------- odoo_repository/views/odoo_module_branch.xml | 1 + 3 files changed, 98 insertions(+), 61 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index b9d388ac..0ace267d 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -241,6 +241,7 @@ def _get_module_paths_updated( """Return modules updated between `from_commit` and `to_commit`. It returns a list of tuples `[(module, last_commit), ...]`. + If a module has been removed, the tuple returned will be `(module, None)`. """ # Clean up 'relative_path' to make it compatible with 'git.Tree' object relative_tree_path = "/".join( @@ -269,15 +270,22 @@ def _get_module_paths_updated( rel_path = pathlib.Path(relative_path) diff_path = pathlib.Path(diff.a_path) module_path = pathlib.Path(*diff_path.parts[: len(rel_path.parts) + 1]) - tree = to_commit.tree / str(module_path) - if self._odoo_module(tree): - module_paths.add( - # FIXME: should we return pathlib.Path objects? - ( - tree.path, - self._get_last_commit_of_git_tree(f"origin/{branch}", tree), + tree = self._get_subtree(to_commit.tree, str(module_path)) + if tree: + # Module still exists + if self._odoo_module(tree): + module_paths.add( + # FIXME: should we return pathlib.Path objects? + ( + tree.path, + self._get_last_commit_of_git_tree(f"origin/{branch}", tree), + ) ) - ) + else: + # Module removed + tree = self._get_subtree(from_commit.tree, str(module_path)) + if self._odoo_module(tree): + module_paths.add((tree.path, None)) return module_paths def _filter_file_path(self, path): @@ -716,21 +724,29 @@ def _scan_module( # modules. if last_module_scanned_commit == last_module_commit: return - _logger.info( - "%s#%s: scan '%s' ", - self.full_name, - branch, - module_path, - ) - data = self._run_module_code_analysis( - module_path, branch, last_module_scanned_commit, last_module_commit - ) - if data["manifest"]: - # Insert all flags 'is_standard', 'is_enterprise', etc - data.update(addons_path_data) - # Set the last fetched commit as last scanned commit - data["last_scanned_commit"] = last_module_commit - self._push_scanned_data(repo_branch_id, module, data) + data = {} + if last_module_commit: + _logger.info( + "%s#%s: scan '%s' ", + self.full_name, + branch, + module_path, + ) + data = self._run_module_code_analysis( + module_path, branch, last_module_scanned_commit, last_module_commit + ) + else: + _logger.info( + "%s#%s: '%s' removed", + self.full_name, + branch, + module_path, + ) + # Insert all flags 'is_standard', 'is_enterprise', etc + data.update(addons_path_data) + # Set the last fetched commit as last scanned commit + data["last_scanned_commit"] = last_module_commit + self._push_scanned_data(repo_branch_id, module, data) return data def _run_module_code_analysis(self, module_path, branch, from_commit, to_commit): diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 3bbb756b..413dded7 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -146,6 +146,7 @@ class OdooModuleBranch(models.Model): sloc_js = fields.Integer("JS", help="JavaScript source lines of code") sloc_css = fields.Integer("CSS", help="CSS source lines of code") last_scanned_commit = fields.Char() + removed = fields.Boolean() addons_path = fields.Char( help="Technical field. Where the module is located in the repository." ) @@ -247,51 +248,74 @@ def _prepare_module_branch_values(self, repo_branch, module, data): # Get existing module.branch if any module_branch = self._get_module_branch(repo_branch, module) # Prepare the 'odoo.module.branch' values - manifest = data["manifest"] - category_id = self._get_module_category_id(manifest.get("category", "")) - author_ids = self._get_author_ids(manifest.get("author", "")) - maintainer_ids = self._get_maintainer_ids( - tuple(manifest.get("maintainers", [])) - ) - dev_status_id = self._get_dev_status_id(manifest.get("development_status", "")) - dependency_ids = self._get_dependency_ids( - repo_branch, manifest.get("depends", []) - ) - external_dependencies = manifest.get("external_dependencies", {}) - python_dependency_ids = self._get_python_dependency_ids( - tuple(external_dependencies.get("python", [])) - ) - license_id = self._get_license_id(manifest.get("license", "")) + manifest = data.get("manifest", {}) values = { "repository_branch_id": repo_branch.id, "branch_id": repo_branch.branch_id.id, "module_id": module.id, - "title": manifest.get("name", False), - "summary": manifest.get("summary", manifest.get("description", False)), - "category_id": category_id, - "author_ids": [(6, 0, author_ids)], - "maintainer_ids": [(6, 0, maintainer_ids)], - "dependency_ids": [(6, 0, dependency_ids)], - "external_dependencies": external_dependencies, - "python_dependency_ids": [(6, 0, python_dependency_ids)], - "license_id": license_id, - "version": manifest.get("version", False), - "development_status_id": dev_status_id, - "application": manifest.get("application", False), - "installable": manifest.get("installable", True), - "auto_install": manifest.get("auto_install", False), "is_standard": data["is_standard"], "is_enterprise": data["is_enterprise"], "is_community": data["is_community"], - "sloc_python": data["code"]["Python"], - "sloc_xml": data["code"]["XML"], - "sloc_js": data["code"]["JavaScript"], - "sloc_css": data["code"]["CSS"], "last_scanned_commit": data.get("last_scanned_commit", False), "addons_path": data["relative_path"], # Unset PR URL once the module is available in the repository. "pr_url": False, } + if manifest: + category_id = self._get_module_category_id(manifest.get("category", "")) + author_ids = self._get_author_ids(manifest.get("author", "")) + maintainer_ids = self._get_maintainer_ids( + tuple(manifest.get("maintainers", [])) + ) + dev_status_id = self._get_dev_status_id( + manifest.get("development_status", "") + ) + dependency_ids = self._get_dependency_ids( + repo_branch, manifest.get("depends", []) + ) + external_dependencies = manifest.get("external_dependencies", {}) + python_dependency_ids = self._get_python_dependency_ids( + tuple(external_dependencies.get("python", [])) + ) + license_id = self._get_license_id(manifest.get("license", "")) + values.update( + { + "title": manifest.get("name", False), + "summary": manifest.get( + "summary", manifest.get("description", False) + ), + "category_id": category_id, + "author_ids": [(6, 0, author_ids)], + "maintainer_ids": [(6, 0, maintainer_ids)], + "dependency_ids": [(6, 0, dependency_ids)], + "external_dependencies": external_dependencies, + "python_dependency_ids": [(6, 0, python_dependency_ids)], + "license_id": license_id, + "version": manifest.get("version", False), + "development_status_id": dev_status_id, + "application": manifest.get("application", False), + "installable": manifest.get("installable", True), + "auto_install": manifest.get("auto_install", False), + } + ) + if data.get("last_scanned_commit"): + values.update( + { + "sloc_python": data["code"]["Python"], + "sloc_xml": data["code"]["XML"], + "sloc_js": data["code"]["JavaScript"], + "sloc_css": data["code"]["CSS"], + } + ) + # Handle module removal + elif module_branch: + values.update( + { + "installable": False, + "removed": True, + } + ) + # Handle versions history versions = self._prepare_module_branch_version_ids_values( repo_branch, module_branch, @@ -321,11 +345,7 @@ def _create_or_update(self, repo_branch, module, values): # attached to repo OCA/x # 5. we scan odoo/odoo and find module A there, as odoo/odoo has a # higher priority, it is replacing OCA/x as original repo of module A - args = [ - ("branch_id", "=", repo_branch.branch_id.id), - ("module_id", "=", module.id), - ] - module_branch = self.search(args) + module_branch = self._get_module_branch(repo_branch, module) if module_branch: if ( module_branch.repository_id.sequence diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index 40eb3093..e5714a7c 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -39,6 +39,7 @@ attrs="{'invisible': [('pr_url', '=', True)]}" /> + From 064dadeb838e75566c6b4006c5c209843d92f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 23 May 2024 18:02:47 +0200 Subject: [PATCH 045/160] odoo_repository: fix singleton error in 'action_scan' method --- odoo_repository/models/odoo_repository_branch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index 755cc7f1..344f8baf 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -55,7 +55,7 @@ def _compute_active(self): def action_scan(self, force=False): """Scan the repository/branch.""" return self.repository_id.action_scan( - branches=[self.branch_id.name], force=force + branches=self.branch_id.mapped("name"), force=force ) def action_force_scan(self): From a887fb8510cf987cf10166f4ca64dc5c505a04e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 13 Jun 2024 17:25:07 +0200 Subject: [PATCH 046/160] odoo_repository: fix module removal 'removed' flag has to be reset when a module has been moved from one repository to another one. A removed module doesn't have any 'version' defined too. --- odoo_repository/models/odoo_module_branch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 413dded7..c9445fb0 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -301,6 +301,7 @@ def _prepare_module_branch_values(self, repo_branch, module, data): if data.get("last_scanned_commit"): values.update( { + "removed": False, "sloc_python": data["code"]["Python"], "sloc_xml": data["code"]["XML"], "sloc_js": data["code"]["JavaScript"], @@ -325,7 +326,11 @@ def _prepare_module_branch_values(self, repo_branch, module, data): # current manifest version if any but without commit. versions=( data.get("versions") - or ({values["version"]: {"commit": None}} if values["version"] else {}) + or ( + {values["version"]: {"commit": None}} + if values.get("version") + else {} + ) ), ) if versions: From c8861767046feb826cf4c52a68f2994fd8f3bd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 14 Jun 2024 09:24:04 +0200 Subject: [PATCH 047/160] odoo_repository: do not set repo type always required Make repo type required only if repository has to be scanned, allowing the declaration of repositories on which we do not have access for instance (on-premise projects), but required to declare projects. --- odoo_repository/models/odoo_repository.py | 1 - odoo_repository/views/odoo_repository.xml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index c7d9bb36..37ba7f81 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -54,7 +54,6 @@ class OdooRepository(models.Model): ("github", "GitHub"), ("gitlab", "GitLab"), ], - required=True, ) ssh_key_id = fields.Many2one( comodel_name="ssh.key", diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index 6939ecd3..4c5e5ee6 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -48,7 +48,7 @@ /> Date: Fri, 12 Jul 2024 09:26:48 +0200 Subject: [PATCH 048/160] odoo_repository: add dependency levels of modules Two new fields available on modules: - Global Dependency Level: include all standard Odoo modules, e.g an OCA module depending on `base` will get its dependency level set to 2, - Non-Std Dependency Level: exclude all standard Odoo modules, e.g. an OCA module depending on any std module will get its dependency level set to 1. --- odoo_repository/models/odoo_module_branch.py | 41 ++++++++++++ odoo_repository/tests/__init__.py | 1 + .../tests/test_module_dependency_level.py | 66 +++++++++++++++++++ odoo_repository/views/odoo_module_branch.xml | 16 +++-- 4 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 odoo_repository/tests/test_module_dependency_level.py diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index c9445fb0..984671be 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -112,6 +112,20 @@ class OdooModuleBranch(models.Model): column2="module_branch_id", string="Reverse Dependencies", ) + global_dependency_level = fields.Integer( + compute="_compute_dependency_level", + recursive=True, + store=True, + string="Global Dep. Level", + help="Dependency level including all standard Odoo modules.", + ) + non_std_dependency_level = fields.Integer( + compute="_compute_dependency_level", + recursive=True, + store=True, + string="Non-Std Dep. Level", + help="Dependency level excluding all standard Odoo modules.", + ) license_id = fields.Many2one( comodel_name="odoo.license", ondelete="restrict", @@ -178,6 +192,33 @@ def _compute_name(self): f"{rec.repository_branch_id.name or '?'}" f" - {rec.module_id.name}" ) + @api.depends( + "dependency_ids.global_dependency_level", + "dependency_ids.non_std_dependency_level", + "dependency_ids.is_standard", + ) + def _compute_dependency_level(self): + for rec in self: + global_max_parent_level = max( + [dep.global_dependency_level for dep in rec.dependency_ids] + [0] + ) + rec.global_dependency_level = global_max_parent_level + 1 + non_std_max_parent_level = max( + [ + dep.non_std_dependency_level + for dep in rec.dependency_ids + if not dep.is_standard + ] + + [0] + ) + rec.non_std_dependency_level = ( + # Set 0 on all std modules so they will always have a dependency + # level inferior to non-std modules + (non_std_max_parent_level + 1) + if not rec.is_standard + else 0 + ) + def action_find_pr_url(self): """Find the PR on GitHub that adds this module.""" self.ensure_one() diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index 50c9d566..ce6ab008 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_base_scanner from . import test_repository_scanner from . import test_sync_node +from . import test_module_dependency_level diff --git a/odoo_repository/tests/test_module_dependency_level.py b/odoo_repository/tests/test_module_dependency_level.py new file mode 100644 index 00000000..a6462f1a --- /dev/null +++ b/odoo_repository/tests/test_module_dependency_level.py @@ -0,0 +1,66 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestOdooModuleDependencyLevel(Common): + def _create_odoo_module(self, name): + return self.env["odoo.module"].create({"name": name}) + + def _create_odoo_module_branch(self, module, branch, **values): + vals = { + "module_id": module.id, + "branch_id": branch.id, + } + vals.update(values) + return self.env["odoo.module.branch"].create(vals) + + def test_dependency_level(self): + # base module in the dependencies tree + mod_base = self._create_odoo_module("base") + mod_base_branch = self._create_odoo_module_branch( + mod_base, self.branch, is_standard=True + ) + self.assertEqual(mod_base_branch.global_dependency_level, 1) + self.assertEqual(mod_base_branch.non_std_dependency_level, 0) + # add a standard module depending on the base one + mod_std = self._create_odoo_module("std") + mod_std_branch = self._create_odoo_module_branch( + mod_std, + self.branch, + is_standard=True, + dependency_ids=[(4, mod_base_branch.id)], + ) + self.assertEqual(mod_std_branch.global_dependency_level, 2) + self.assertEqual(mod_std_branch.non_std_dependency_level, 0) + # add a non-standard module depending on the std one + mod_non_std = self._create_odoo_module("non_std") + mod_non_std_branch = self._create_odoo_module_branch( + mod_non_std, + self.branch, + is_standard=False, + dependency_ids=[(4, mod_std_branch.id)], + ) + self.assertEqual(mod_non_std_branch.global_dependency_level, 3) + self.assertEqual(mod_non_std_branch.non_std_dependency_level, 1) + # add another one depending on base module + mod_non_std2 = self._create_odoo_module("non_std2") + mod_non_std2_branch = self._create_odoo_module_branch( + mod_non_std2, + self.branch, + is_standard=False, + dependency_ids=[(4, mod_base_branch.id)], + ) + self.assertEqual(mod_non_std2_branch.global_dependency_level, 2) + self.assertEqual(mod_non_std2_branch.non_std_dependency_level, 1) + # add another one depending on non-std module + mod_non_std3 = self._create_odoo_module("non_std3") + mod_non_std3_branch = self._create_odoo_module_branch( + mod_non_std3, + self.branch, + is_standard=False, + dependency_ids=[(4, mod_non_std_branch.id)], + ) + self.assertEqual(mod_non_std3_branch.global_dependency_level, 4) + self.assertEqual(mod_non_std3_branch.non_std_dependency_level, 2) diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index e5714a7c..bca05a90 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -43,6 +43,8 @@ + + @@ -68,8 +70,10 @@ - - + + + + @@ -79,8 +83,10 @@ - - + + + + @@ -118,6 +124,8 @@ + + From ed61c26abfceed2a9181aabf8667c48de888b6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 31 Jul 2024 17:12:11 +0200 Subject: [PATCH 049/160] BaseScanner: workaround FS errors when cloning repositories --- odoo_repository/lib/scanner.py | 87 ++++++++++++++++++---- odoo_repository/tests/test_base_scanner.py | 13 ++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 0ace267d..f2f8bb4f 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -8,6 +8,7 @@ import os import pathlib import re +import shutil import tempfile import time from urllib.parse import urlparse, urlunparse @@ -69,6 +70,7 @@ def __init__( repo_type: str = None, ssh_key: str = None, token: str = None, + workaround_fs_errors: bool = False, ): self.org = org self.name = name @@ -79,8 +81,10 @@ def __init__( self.repo_type = repo_type self.ssh_key = ssh_key self.token = token + self.workaround_fs_errors = workaround_fs_errors def scan(self, fetch=True): + self._apply_git_global_config() # Clone or update the repository if not self.is_cloned: self._clone() @@ -138,15 +142,27 @@ def _prepare_repositories_path(cls, repositories_path=None): repositories_path.mkdir(parents=True, exist_ok=True) return repositories_path + def _apply_git_global_config(self): + # Avoid 'fatal: detected dubious ownership in repository' errors + # when performing operations in git repositories in case they are + # cloned on an mounted filesystem with specific options. + # NOTE: ensure to unset existing entry before adding one, as git doesn't + # check if an entry already exists, generating duplicates + os.system('git config --global --unset safe.directory "%s"' % (self.path)) + os.system('git config --global --add safe.directory "%s"' % (self.path)) + def _apply_git_config(self): - # This avoids too high memory consumption (default git config could - # crash the Odoo workers when the scanner is run by Odoo itself). - # This is especially useful to checkout big repositories like odoo/odoo. with self.repo.config_writer() as writer: + # This avoids too high memory consumption (default git config could + # crash the Odoo workers when the scanner is run by Odoo itself). + # This is especially useful to checkout big repositories like odoo/odoo. writer.set_value("core", "packedGitLimit", "128m") writer.set_value("core", "packedGitWindowSize", "32m") writer.set_value("pack", "windowMemory", "64m") writer.set_value("pack", "threads", "1") + # Avoid issues with file permissions for mounted filesystems + # with specific options. + writer.set_value("core", "filemode", "false") def _set_git_remote_url(self): """Ensure that 'origin' remote is set with the right URL.""" @@ -164,19 +180,42 @@ def repo(self): def full_name(self): return f"{self.org}/{self.name}" - def _clone(self): - _logger.info("Cloning %s...", self.full_name) - with self._get_git_env() as git_env: + def _clone_params(self, **extra): + params = { + "url": self.clone_url, + "to_path": self.path, # NOTE: adding 'no_checkout' and 'filter=blob:none' allows fast # cloning and reduce memory usage. Blobs will be fetched later on # demand, once the git config to reduce memory usage is applied. - git.Repo.clone_from( - self.clone_url, - self.path, - env=git_env, - no_checkout=True, - filter="blob:none", - ) + "no_checkout": True, + "filter": "blob:none", + # Avoid issues with file permissions + # "allow_unsafe_options": True, + # "multi_options": ["--config core.filemode=false"], + } + params.update(extra) + return params + + def _clone(self): + _logger.info("Cloning %s...", self.full_name) + tmp_git_dir_path = None + repo_git_dir_path = pathlib.Path(self.path, ".git") + with tempfile.TemporaryDirectory() as tmp_git_dir: + if self.workaround_fs_errors: + tmp_git_dir_path = pathlib.Path(tmp_git_dir).joinpath(".git") + with self._get_git_env() as git_env: + extra = {"env": git_env} + if self.workaround_fs_errors: + extra["separate_git_dir"] = str(tmp_git_dir_path) + params = self._clone_params(**extra) + git.Repo.clone_from(**params) + if tmp_git_dir_path: + # {repo_path}/.git folder is a hardlink, replace + # it by the .git folder created in /tmp + # NOTE: use shutil instead of 'pathlib.Path.replace()' as + # file systems could be different + repo_git_dir_path.unlink() + shutil.move(tmp_git_dir_path, repo_git_dir_path) def _fetch(self): repo = self.repo @@ -351,10 +390,19 @@ def __init__( repo_type: str = None, ssh_key: str = None, token: str = None, + workaround_fs_errors: bool = False, ): branches = sorted(set(sum([tuple(mp) for mp in migration_paths], ()))) super().__init__( - org, name, clone_url, branches, repositories_path, repo_type, ssh_key, token + org, + name, + clone_url, + branches, + repositories_path, + repo_type, + ssh_key, + token, + workaround_fs_errors, ) self.migration_paths = migration_paths @@ -612,9 +660,18 @@ def __init__( repo_type: str = None, ssh_key: str = None, token: str = None, + workaround_fs_errors: bool = False, ): super().__init__( - org, name, clone_url, branches, repositories_path, repo_type, ssh_key, token + org, + name, + clone_url, + branches, + repositories_path, + repo_type, + ssh_key, + token, + workaround_fs_errors, ) self.addons_paths_data = addons_paths_data diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index 0c1e3de4..aea26f32 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -195,3 +195,16 @@ def test_get_subtree(self): self.assertTrue(scanner._get_subtree(repo.tree(remote_branch), module)) # Module/folder doesn't exist: KO self.assertFalse(scanner._get_subtree(repo.tree(remote_branch), "none")) + + def test_workaround_fs_errors(self): + scanner = self._init_scanner( + repositories_path=tempfile.mkdtemp(), + workaround_fs_errors=True, + ) + # Clone + self.assertFalse(scanner.path.exists()) + self.assertFalse(scanner.is_cloned) + scanner.scan() + self.assertTrue(scanner.is_cloned) + # Fetch once cloned + scanner.scan() From 02fc90e516914999130d69f6cafef64649f9bf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 31 Jul 2024 17:12:50 +0200 Subject: [PATCH 050/160] odoo_repository: add option to workaround FS errors --- odoo_repository/models/odoo_repository.py | 3 +++ odoo_repository/models/res_company.py | 9 +++++++++ odoo_repository/models/res_config_settings.py | 4 ++++ odoo_repository/tests/test_repository_scanner.py | 4 ++++ odoo_repository/views/res_config_settings.xml | 16 ++++++++++++++++ 5 files changed, 36 insertions(+) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 37ba7f81..5bff4162 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -267,6 +267,9 @@ def _prepare_scanner_parameters(self, branch): "repo_type": self.repo_type, "ssh_key": self.ssh_key_id.private_key, "token": self._get_token(), + "workaround_fs_errors": ( + self.env.company.config_odoo_repository_workaround_fs_errors + ), "env": self.env, } diff --git a/odoo_repository/models/res_company.py b/odoo_repository/models/res_company.py index e7829822..c208251a 100644 --- a/odoo_repository/models/res_company.py +++ b/odoo_repository/models/res_company.py @@ -12,3 +12,12 @@ class ResCompany(models.Model): string="Default token", help="Default token used to clone repositories and authenticate on API like GitHub.", ) + config_odoo_repository_workaround_fs_errors = fields.Boolean( + string="Workaround FS errors", + help=( + "Fix file system permissions when cloning repositories. " + "Errors could be triggered on some file systems when git tries to " + "execute 'chown' commands on its internal configuration files. " + "This option will workaround this issue." + ), + ) diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py index 7e1f20d4..50376d78 100644 --- a/odoo_repository/models/res_config_settings.py +++ b/odoo_repository/models/res_config_settings.py @@ -10,6 +10,10 @@ class ResConfigSettings(models.TransientModel): config_odoo_repository_storage_path = fields.Char( string="Storage local path", config_parameter="odoo_repository_storage_path" ) + config_odoo_repository_workaround_fs_errors = fields.Boolean( + related="company_id.config_odoo_repository_workaround_fs_errors", + readonly=False, + ) config_odoo_repository_default_token_id = fields.Many2one( related="company_id.config_odoo_repository_default_token_id", readonly=False, diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index ff452695..2aa4d95b 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -201,3 +201,7 @@ def test_scan_branch(self): # Second scan: no new commits to scan res = scanner._scan_branch(repo_id, self.branch.name) self.assertFalse(res) + + def test_workaround_fs_errors(self): + scanner = self._init_scanner(workaround_fs_errors=True) + scanner.scan() diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 90fcb9ab..7ec9f74b 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -38,6 +38,22 @@ +
+
+
+ +
+
+ Workaround FS errors +
+ Fix file system permissions when cloning repositories. +
+
+
+
Date: Thu, 1 Aug 2024 10:13:47 +0200 Subject: [PATCH 051/160] odoo_repository: fix, enable 'test_odoo_repository_scan' --- odoo_repository/tests/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index ce6ab008..44837e98 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -1,5 +1,6 @@ from . import test_utils from . import test_base_scanner from . import test_repository_scanner +from . import test_odoo_repository_scan from . import test_sync_node from . import test_module_dependency_level From cf6d260cf4222c2d10a3dec02724acf58ab835c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 20 Aug 2024 17:34:57 +0200 Subject: [PATCH 052/160] BaseScanner: always use remote URL provided by Odoo Always use remote URL provided by Odoo (as config could change in Odoo, like the oAuth token) by using this one when fetching commits. Addition: if the fetch operation fails, raise the exception. Also avoid useless updates of remote URL if this one didn't changed, as git triggers 'chmod' commands on its config file when some options hosting sensitive data are updated (e.g. remote URL could contain a oAuth token or basic auth credentials), operations that could not be allowed on some file systems (e.g. mount options). --- odoo_repository/lib/scanner.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index f2f8bb4f..cc506540 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -166,7 +166,11 @@ def _apply_git_config(self): def _set_git_remote_url(self): """Ensure that 'origin' remote is set with the right URL.""" - self.repo.remotes["origin"].set_url(self.clone_url) + # Check first the URL before setting it, as this triggers a 'chmod' + # command on '.git/config' file (to protect sensitive data) that could + # be not allowed on some mounted file systems. + if self.repo.remotes["origin"].url != self.clone_url: + self.repo.remotes["origin"].set_url(self.clone_url) @property def is_cloned(self): @@ -227,9 +231,22 @@ def _fetch(self): try: with self._get_git_env() as git_env: with repo.git.custom_environment(**git_env): - repo.remotes.origin.fetch(branch) + # Make sure to use up-to-date `clone_url` when fetching + # repository (e.g. it could have been cloned without a + # OAuth token at first, and one could have been set + # later on). + # By doing so we are not forced to store the remote URL + # in the configuration file that is triggering a 'chmod' + # command by git (to protect sensitive data) and such + # command could not work on some mounted file systems. + repo.git.fetch( + self.clone_url, + f"refs/heads/{branch}:origin/{branch}", + "--update-head-ok", + ) except git.exc.GitCommandError as exc: - _logger.info(exc) + _logger.error(exc) + raise else: _logger.info("%s: branch %s fetched", self.full_name, branch) From f6aa49008763728e715f8ffc263a71da26c95fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 20 Aug 2024 17:57:24 +0200 Subject: [PATCH 053/160] odoo_repository: list recursive dependencies from module form --- odoo_repository/models/odoo_module_branch.py | 26 ++++++++++++ odoo_repository/views/odoo_module_branch.xml | 44 ++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 984671be..45fe19b1 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -219,6 +219,32 @@ def _compute_dependency_level(self): else 0 ) + def _get_recursive_dependencies(self, domain=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")]) + + """ + if not domain: + domain = [] + dependencies = self.dependency_ids.filtered_domain(domain) + dep_ids = set(dependencies.ids) + for dep in dependencies: + dep_ids |= set( + dep._get_recursive_dependencies().filtered_domain(domain).ids + ) + return self.browse(dep_ids) + + def open_recursive_dependencies(self): + self.ensure_one() + xml_id = "odoo_repository.odoo_module_branch_action_recursive_dependencies" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = "All dependencies" + action["domain"] = [("id", "in", self._get_recursive_dependencies().ids)] + return action + def action_find_pr_url(self): """Find the PR on GitHub that adds this module.""" self.ensure_one() diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index bca05a90..dfa41181 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -9,6 +9,16 @@ +
+ +
@@ -134,6 +144,30 @@ + + odoo.module.branch.tree.recursive_dependencies + odoo.module.branch + + primary + 100 + + + global_dependency_level, non_std_dependency_level, module_name + + + show + + + show + + + show + + + + odoo.module.branch.search odoo.module.branch @@ -236,6 +270,16 @@ {'search_default_installable': 1} + + Dependencies + ir.actions.act_window + odoo.module.branch + + + Date: Tue, 20 Aug 2024 18:17:14 +0200 Subject: [PATCH 054/160] odoo_repository: remove readonly modifiers on 'odoo.repository' form We should be able to update some data once the repository is cloned/analyzed (clone URL for instance, switching from SSH to HTTP). --- odoo_repository/views/odoo_repository.xml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index 4c5e5ee6..d7439f4e 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -33,22 +33,15 @@ - + - + Date: Fri, 23 Aug 2024 17:37:10 +0200 Subject: [PATCH 055/160] Scanner: do not raise an error if a branch doesn't exist While Odoo is configured to scan a set of branches, one of them could not exist on a given repository, and this should not be blocking to run the remaining jobs on other (next) branches. --- odoo_repository/lib/scanner.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index cc506540..555a0793 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -87,11 +87,12 @@ def scan(self, fetch=True): self._apply_git_global_config() # Clone or update the repository if not self.is_cloned: - self._clone() + res = self._clone() self._apply_git_config() self._set_git_remote_url() if fetch: - self._fetch() + res = self._fetch() + return res @contextlib.contextmanager def _get_git_env(self): @@ -220,14 +221,17 @@ def _clone(self): # file systems could be different repo_git_dir_path.unlink() shutil.move(tmp_git_dir_path, repo_git_dir_path) + return True def _fetch(self): repo = self.repo _logger.info( "%s: fetch branch(es) %s", self.full_name, ", ".join(self.branches) ) + branches_fetched = [] for branch in self.branches: # Do not block the process if the branch doesn't exist on this repo + refs_heads_branch = f"refs/heads/{branch}" try: with self._get_git_env() as git_env: with repo.git.custom_environment(**git_env): @@ -241,14 +245,23 @@ def _fetch(self): # command could not work on some mounted file systems. repo.git.fetch( self.clone_url, - f"refs/heads/{branch}:origin/{branch}", + f"{refs_heads_branch}:origin/{branch}", "--update-head-ok", ) except git.exc.GitCommandError as exc: _logger.error(exc) + branch_not_find_error = f"couldn't find remote ref {refs_heads_branch}" + if branch_not_find_error in str(exc): + _logger.info( + "Couldn't find remote branch %s, skipping.", self.full_name + ) + return False raise else: _logger.info("%s: branch %s fetched", self.full_name, branch) + branches_fetched.append(branch) + # Return True as soon as we fetched at least one branch + return bool(branches_fetched) def _branch_exists(self, branch): repo = self.repo @@ -427,6 +440,10 @@ def scan(self): # Clone/fetch has been done during the repository scan, the migration # scan will be processed on the current history of commits res = super().scan(fetch=False) + # 'super()' could return False if the branch to scan doesn't exist, + # there is nothing to scan then. + if not res: + return False for source_branch, target_branch in self.migration_paths: if self._branch_exists(source_branch) and self._branch_exists( target_branch @@ -694,6 +711,10 @@ def __init__( def scan(self): res = super().scan() + # 'super()' could return False if the branch to scan doesn't exist, + # there is nothing to scan then. + if not res: + return False repo_id = self._get_odoo_repository_id() branches_scanned = {} for branch in self.branches: From 00d4057a7ecc5783915324b9c0b9d5a90337dfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 23 Aug 2024 17:41:24 +0200 Subject: [PATCH 056/160] Scanner: do not clone/fetch blobs Not downloading the whole history of blobs allows very fast clone/fetch operations while reducing the memory footprint. By doing so, only required blobs will be downloaded on demand when the scan will be performed. --- odoo_repository/lib/scanner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 555a0793..df1766ae 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -247,6 +247,8 @@ def _fetch(self): self.clone_url, f"{refs_heads_branch}:origin/{branch}", "--update-head-ok", + # Increase performance + "--filter=blob:none", ) except git.exc.GitCommandError as exc: _logger.error(exc) From 24d362051d32ee43b09d0c5905efd91e3975401c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 26 Aug 2024 08:42:23 +0200 Subject: [PATCH 057/160] Scanner: disable some git GC features To increase performance by reducing IO and memory consumption. --- odoo_repository/lib/scanner.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index df1766ae..1124961e 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -164,6 +164,15 @@ def _apply_git_config(self): # Avoid issues with file permissions for mounted filesystems # with specific options. writer.set_value("core", "filemode", "false") + # Disable some GC features for performance (memory and IO) + # Reflog clean up is triggered automatically by some commands. + # We assume that we scan upstream branches that will never contain + # orphaned commits to clean up, so some GC features are useless in + # this context. + writer.set_value("gc", "pruneExpire", "never") + writer.set_value("gc", "worktreePruneExpire", "never") + writer.set_value("gc", "reflogExpire", "never") + writer.set_value("gc", "reflogExpireUnreachable", "never") def _set_git_remote_url(self): """Ensure that 'origin' remote is set with the right URL.""" From 28ee679b3166a823c1378b9f91f0c32656f11469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 26 Aug 2024 08:44:09 +0200 Subject: [PATCH 058/160] odoo_repository: fix access right on 'odoo.module.branch.version' --- odoo_repository/security/ir.model.access.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index c2c85951..77c16d40 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -23,3 +23,4 @@ access_odoo_repository_branch_user,odoo_repository_branch_user,model_odoo_reposi access_odoo_repository_branch_manager,odoo_repository_branch_manager,model_odoo_repository_branch,base.group_system,1,1,1,1 access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch,base.group_user,1,0,0,0 access_odoo_module_branch_version_user,odoo_module_branch_version_user,model_odoo_module_branch_version,base.group_user,1,0,0,0 +access_odoo_module_branch_version_manager,odoo_module_branch_version_manager,model_odoo_module_branch_version,base.group_system,1,1,1,1 From d18795aba70eb2819537099ecdb747debab6a666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 26 Aug 2024 09:25:27 +0200 Subject: [PATCH 059/160] Scanner: remove dangling 'git' process Ensure to delete `git.Repo` objects after use, so their attached 'git' processes are deleted as well. Without this change a long running Odoo process will pollutes the system process tree. --- odoo_repository/lib/scanner.py | 123 +++++++----- odoo_repository/tests/test_base_scanner.py | 189 +++++++++--------- .../tests/test_repository_scanner.py | 141 +++++++------ 3 files changed, 246 insertions(+), 207 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 1124961e..aeda0d68 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -88,10 +88,11 @@ def scan(self, fetch=True): # Clone or update the repository if not self.is_cloned: res = self._clone() - self._apply_git_config() - self._set_git_remote_url() - if fetch: - res = self._fetch() + with self.repo() as repo: + self._apply_git_config(repo) + self._set_git_remote_url(repo) + if fetch: + res = self._fetch(repo) return res @contextlib.contextmanager @@ -152,8 +153,8 @@ def _apply_git_global_config(self): os.system('git config --global --unset safe.directory "%s"' % (self.path)) os.system('git config --global --add safe.directory "%s"' % (self.path)) - def _apply_git_config(self): - with self.repo.config_writer() as writer: + def _apply_git_config(self, repo): + with repo.config_writer() as writer: # This avoids too high memory consumption (default git config could # crash the Odoo workers when the scanner is run by Odoo itself). # This is especially useful to checkout big repositories like odoo/odoo. @@ -174,21 +175,25 @@ def _apply_git_config(self): writer.set_value("gc", "reflogExpire", "never") writer.set_value("gc", "reflogExpireUnreachable", "never") - def _set_git_remote_url(self): + def _set_git_remote_url(self, repo): """Ensure that 'origin' remote is set with the right URL.""" # Check first the URL before setting it, as this triggers a 'chmod' # command on '.git/config' file (to protect sensitive data) that could # be not allowed on some mounted file systems. - if self.repo.remotes["origin"].url != self.clone_url: - self.repo.remotes["origin"].set_url(self.clone_url) + if repo.remotes["origin"].url != self.clone_url: + repo.remotes["origin"].set_url(self.clone_url) @property def is_cloned(self): return self.path.joinpath(".git").exists() - @property + @contextlib.contextmanager def repo(self): - return git.Repo(self.path) + repo = git.Repo(self.path) + try: + yield repo + finally: + del repo @property def full_name(self): @@ -232,8 +237,7 @@ def _clone(self): shutil.move(tmp_git_dir_path, repo_git_dir_path) return True - def _fetch(self): - repo = self.repo + def _fetch(self, repo): _logger.info( "%s: fetch branch(es) %s", self.full_name, ", ".join(self.branches) ) @@ -274,24 +278,22 @@ def _fetch(self): # Return True as soon as we fetched at least one branch return bool(branches_fetched) - def _branch_exists(self, branch): - repo = self.repo + def _branch_exists(self, repo, branch): refs = [r.name for r in repo.remotes.origin.refs] branch = f"origin/{branch}" return branch in refs - def _checkout_branch(self, branch): + def _checkout_branch(self, repo, branch): # Ensure to clean up the repository before a checkout - self.repo.git.reset("--hard") - self.repo.git.clean("-xdf") - self.repo.refs[f"origin/{branch}"].checkout() + repo.git.reset("--hard") + repo.git.clean("-xdf") + repo.refs[f"origin/{branch}"].checkout() - def _get_last_fetched_commit(self, branch): + def _get_last_fetched_commit(self, repo, branch): """Return the last fetched commit for the given `branch`.""" - repo = self.repo return repo.rev_parse(f"origin/{branch}").hexsha - def _get_module_paths(self, relative_path, branch): + def _get_module_paths(self, repo, relative_path, branch): """Return modules available in `branch`. It returns a list of tuples `[(module, last_commit), ...]`. @@ -301,18 +303,20 @@ def _get_module_paths(self, relative_path, branch): [dir_ for dir_ in relative_path.split("/") if dir_ and dir_ != "."] ) # No from_commit means first scan: return all available modules - branch_commit = self.repo.refs[f"origin/{branch}"].commit + branch_commit = repo.refs[f"origin/{branch}"].commit addons_trees = branch_commit.tree.trees if relative_tree_path: addons_trees = (branch_commit.tree / relative_tree_path).trees - return [ + module_paths = [ (tree.path, self._get_last_commit_of_git_tree(f"origin/{branch}", tree)) for tree in addons_trees if self._odoo_module(tree) ] + return module_paths def _get_module_paths_updated( self, + repo, relative_path, from_commit, to_commit, @@ -331,7 +335,6 @@ def _get_module_paths_updated( # Same commits: nothing has changed if from_commit == to_commit: return module_paths - repo = self.repo # Get only modules updated between the two commits from_commit = repo.commit(from_commit) to_commit = repo.commit(to_commit) @@ -456,17 +459,17 @@ def scan(self): if not res: return False for source_branch, target_branch in self.migration_paths: - if self._branch_exists(source_branch) and self._branch_exists( - target_branch - ): - self._scan_migration_path(source_branch, target_branch) + with self.repo() as repo: + if self._branch_exists(repo, source_branch) and self._branch_exists( + repo, target_branch + ): + self._scan_migration_path(repo, source_branch, target_branch) return res - def _scan_migration_path(self, source_branch, target_branch): - repo = self.repo - repo_source_commit = self._get_last_fetched_commit(source_branch) - repo_target_commit = self._get_last_fetched_commit(target_branch) - modules = self._get_module_paths(".", source_branch) + def _scan_migration_path(self, repo, source_branch, target_branch): + repo_source_commit = self._get_last_fetched_commit(repo, source_branch) + repo_target_commit = self._get_last_fetched_commit(repo, target_branch) + modules = self._get_module_paths(repo, ".", source_branch) for module, __ in modules: if self._is_module_blacklisted(module): _logger.info( @@ -511,6 +514,7 @@ def _scan_migration_path(self, source_branch, target_branch): or data.get("last_target_scanned_commit") != module_target_commit ): self._scan_module( + repo, module, module_branch_id, source_branch, @@ -523,6 +527,7 @@ def _scan_migration_path(self, source_branch, target_branch): def _scan_module( self, + repo: git.Repo, module: str, module_branch_id: int, source_branch: str, @@ -544,6 +549,7 @@ def _scan_module( # (e.g. all new commits are updating PO files), we skip the scan but # we still push the new source/target commits to Odoo. scan_relevant = self._is_scan_module_relevant( + repo, module, source_commit, target_commit, @@ -568,6 +574,7 @@ def _scan_module( def _is_scan_module_relevant( self, + repo: git.Repo, module: str, source_commit: str, target_commit: str, @@ -592,21 +599,19 @@ def _is_scan_module_relevant( return True # Other cases: check files impacted by new commits both on source & target # branches to tell if a scan should be processed - repo = self.repo source_tree = self._get_subtree(repo.commit(source_commit).tree, module) target_tree = self._get_subtree(repo.commit(target_commit).tree, module) source_new_commits = self._get_commits_of_git_tree( source_last_scanned_commit, source_commit, source_tree ) - source_to_scan = self._check_relevant_commits(module, source_new_commits) + source_to_scan = self._check_relevant_commits(repo, module, source_new_commits) target_new_commits = self._get_commits_of_git_tree( target_last_scanned_commit, target_commit, target_tree ) - target_to_scan = self._check_relevant_commits(module, target_new_commits) + target_to_scan = self._check_relevant_commits(repo, module, target_new_commits) return source_to_scan or target_to_scan - def _check_relevant_commits(self, module, commits): - repo = self.repo + def _check_relevant_commits(self, repo, module, commits): paths = set() for commit_sha in commits: commit = repo.commit(commit_sha) @@ -728,25 +733,27 @@ def scan(self): return False repo_id = self._get_odoo_repository_id() branches_scanned = {} - for branch in self.branches: - branches_scanned[branch] = self._scan_branch(repo_id, branch) + with self.repo() as repo: + for branch in self.branches: + branches_scanned[branch] = self._scan_branch(repo, repo_id, branch) return res - def _scan_branch(self, repo_id, branch): - if not self._branch_exists(branch): + def _scan_branch(self, repo, repo_id, branch): + if not self._branch_exists(repo, branch): return branch_id = self._get_odoo_branch_id(repo_id, branch) repo_branch_id = self._create_odoo_repository_branch(repo_id, branch_id) - last_fetched_commit = self._get_last_fetched_commit(branch) + last_fetched_commit = self._get_last_fetched_commit(repo, branch) last_scanned_commit = self._get_repo_last_scanned_commit(repo_branch_id) if last_fetched_commit != last_scanned_commit: # Checkout the source branch to: # - get the last commit of a module working tree # - perform module code analysis - self._checkout_branch(branch) + self._checkout_branch(repo, branch) # Scan relevant subfolders of the repository for addons_path_data in self.addons_paths_data: self._scan_addons_path( + repo, addons_path_data, branch, repo_branch_id, @@ -760,6 +767,7 @@ def _scan_branch(self, repo_id, branch): def _scan_addons_path( self, + repo, addons_path_data, branch, repo_branch_id, @@ -768,12 +776,13 @@ def _scan_addons_path( ): if not last_scanned_commit: module_paths = sorted( - self._get_module_paths(addons_path_data["relative_path"], branch) + self._get_module_paths(repo, addons_path_data["relative_path"], branch) ) else: # Get module paths updated since the last scanned commit module_paths = sorted( self._get_module_paths_updated( + repo, addons_path_data["relative_path"], from_commit=last_scanned_commit, to_commit=last_fetched_commit, @@ -793,6 +802,7 @@ def _scan_addons_path( modules_scanned = {} for module_path, last_module_commit in module_paths: self._scan_module( + repo, branch, repo_branch_id, module_path, @@ -805,6 +815,7 @@ def _scan_addons_path( def _scan_module( self, + repo, branch, repo_branch_id, module_path, @@ -839,7 +850,11 @@ def _scan_module( module_path, ) data = self._run_module_code_analysis( - module_path, branch, last_module_scanned_commit, last_module_commit + repo, + module_path, + branch, + last_module_scanned_commit, + last_module_commit, ) else: _logger.info( @@ -855,22 +870,23 @@ def _scan_module( self._push_scanned_data(repo_branch_id, module, data) return data - def _run_module_code_analysis(self, module_path, branch, from_commit, to_commit): + def _run_module_code_analysis( + self, repo, module_path, branch, from_commit, to_commit + ): """Perform a code analysis of `module_path`.""" # Get current code analysis data module_analysis = ModuleAnalysis(f"{self.path}/{module_path}") data = module_analysis.to_dict() # Append the history of versions versions = self._read_module_versions( - module_path, branch, from_commit, to_commit + repo, module_path, branch, from_commit, to_commit ) data["versions"] = versions return data - def _read_module_versions(self, module_path, branch, from_commit, to_commit): + def _read_module_versions(self, repo, module_path, branch, from_commit, to_commit): """Return versions data introduced between `from_commit` and `to_commit`.""" versions = {} - repo = self.repo for manifest_file in MANIFEST_FILES: manifest_path = "/".join([module_path, manifest_file]) manifest_tree = self._get_subtree( @@ -882,17 +898,16 @@ def _read_module_versions(self, module_path, branch, from_commit, to_commit): from_commit, to_commit, manifest_tree ) versions_ = self._parse_module_versions_from_commits( - module_path, manifest_path, branch, new_commits + repo, module_path, manifest_path, branch, new_commits ) versions.update(versions_) return versions def _parse_module_versions_from_commits( - self, module_path, manifest_path, branch, new_commits + self, repo, module_path, manifest_path, branch, new_commits ): """Parse module versions introduced in `new_commits`.""" versions = {} - repo = self.repo for commit_sha in new_commits: commit = repo.commit(commit_sha) if commit.parents: diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index aea26f32..8769a436 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -60,74 +60,85 @@ def test_scan(self): def test_branch_exists(self): scanner = self._init_scanner() scanner.scan() - self.assertTrue(scanner._branch_exists(self._settings["branch1"])) - self.assertTrue(scanner._branch_exists(self._settings["branch2"])) - self.assertTrue(scanner._branch_exists(self._settings["branch3"])) + with scanner.repo() as repo: + self.assertTrue(scanner._branch_exists(repo, self._settings["branch1"])) + self.assertTrue(scanner._branch_exists(repo, self._settings["branch2"])) + self.assertTrue(scanner._branch_exists(repo, self._settings["branch3"])) def test_checkout_branch(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - branch_sha = repo.refs[f"origin/{branch}"].object.hexsha - self.assertNotEqual(repo.head.object.hexsha, branch_sha) - scanner._checkout_branch(branch) - self.assertEqual(repo.head.object.hexsha, branch_sha) + with scanner.repo() as repo: + branch = self._settings["branch1"] + branch_sha = repo.refs[f"origin/{branch}"].object.hexsha + self.assertNotEqual(repo.head.object.hexsha, branch_sha) + scanner._checkout_branch(repo, branch) + self.assertEqual(repo.head.object.hexsha, branch_sha) def test_get_last_fetched_commit(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch1 = self._settings["branch1"] - branch2 = self._settings["branch2"] - branch3 = self._settings["branch3"] - branch1_sha = repo.refs[f"origin/{branch1}"].object.hexsha - branch2_sha = repo.refs[f"origin/{branch2}"].object.hexsha - branch3_sha = repo.refs[f"origin/{branch3}"].object.hexsha - self.assertEqual(scanner._get_last_fetched_commit(branch1), branch1_sha) - self.assertEqual(scanner._get_last_fetched_commit(branch2), branch2_sha) - self.assertEqual(scanner._get_last_fetched_commit(branch3), branch3_sha) + with scanner.repo() as repo: + branch1 = self._settings["branch1"] + branch2 = self._settings["branch2"] + branch3 = self._settings["branch3"] + branch1_sha = repo.refs[f"origin/{branch1}"].object.hexsha + branch2_sha = repo.refs[f"origin/{branch2}"].object.hexsha + branch3_sha = repo.refs[f"origin/{branch3}"].object.hexsha + self.assertEqual( + scanner._get_last_fetched_commit(repo, branch1), branch1_sha + ) + self.assertEqual( + scanner._get_last_fetched_commit(repo, branch2), branch2_sha + ) + self.assertEqual( + scanner._get_last_fetched_commit(repo, branch3), branch3_sha + ) def test_get_module_paths(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - module_paths = scanner._get_module_paths(".", branch) - self.assertEqual(len(module_paths), 1) - self.assertEqual(len(module_paths[0]), 2) - self.assertEqual(module_paths[0][0], self._settings["addon"]) - all_commits = [c.hexsha for c in repo.iter_commits(f"origin/{branch}")] - self.assertIn(module_paths[0][1], all_commits) + with scanner.repo() as repo: + branch = self._settings["branch1"] + module_paths = scanner._get_module_paths(repo, ".", branch) + self.assertEqual(len(module_paths), 1) + self.assertEqual(len(module_paths[0]), 2) + self.assertEqual(module_paths[0][0], self._settings["addon"]) + all_commits = [c.hexsha for c in repo.iter_commits(f"origin/{branch}")] + self.assertIn(module_paths[0][1], all_commits) def test_get_module_paths_updated(self): scanner = self._init_scanner() scanner.scan() branch = self._settings["branch1"] - initial_commit = scanner._get_last_fetched_commit(branch) - # Case where from_commit and to_commit are the same: no change detected - module_paths = scanner._get_module_paths_updated( - relative_path=".", - from_commit=initial_commit, - to_commit=initial_commit, - branch=branch, - ) - self.assertFalse(module_paths) + with scanner.repo() as repo: + initial_commit = scanner._get_last_fetched_commit(repo, branch) + # Case where from_commit and to_commit are the same: no change detected + module_paths = scanner._get_module_paths_updated( + repo, + relative_path=".", + from_commit=initial_commit, + to_commit=initial_commit, + branch=branch, + ) + self.assertFalse(module_paths) # Update the upstream repository with a new commit self._update_module_version_on_branch(branch, "1.0.1") # Module is now detected has updated scanner.scan() # Fetch new commit from upstream repo - last_commit = scanner._get_last_fetched_commit(branch) - module_paths = scanner._get_module_paths_updated( - relative_path=".", - from_commit=initial_commit, - to_commit=last_commit, - branch=branch, - ) - self.assertEqual(len(module_paths), 1) - module_path = module_paths.pop() - self.assertEqual(module_path[0], self._settings["addon"]) - self.assertEqual(module_path[1], last_commit) + with scanner.repo() as repo: + last_commit = scanner._get_last_fetched_commit(repo, branch) + module_paths = scanner._get_module_paths_updated( + repo, + relative_path=".", + from_commit=initial_commit, + to_commit=last_commit, + branch=branch, + ) + self.assertEqual(len(module_paths), 1) + module_path = module_paths.pop() + self.assertEqual(module_path[0], self._settings["addon"]) + self.assertEqual(module_path[1], last_commit) def test_filter_file_path(self): scanner = self._init_scanner() @@ -137,64 +148,64 @@ def test_filter_file_path(self): def test_get_last_commit_of_git_tree(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - remote_branch = f"origin/{branch}" - module = self._settings["addon"] - module_tree = repo.tree(remote_branch) / module - all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] - commit = scanner._get_last_commit_of_git_tree(remote_branch, module_tree) - self.assertIn(commit, all_commits) + with scanner.repo() as repo: + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] + commit = scanner._get_last_commit_of_git_tree(remote_branch, module_tree) + self.assertIn(commit, all_commits) def test_get_commits_of_git_tree(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - remote_branch = f"origin/{branch}" - module = self._settings["addon"] - module_tree = repo.tree(remote_branch) / module - all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] - commits = scanner._get_commits_of_git_tree( - from_=None, to_=remote_branch, tree=module_tree - ) - for commit in commits: - self.assertIn(commit, all_commits) + with scanner.repo() as repo: + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + all_commits = [c.hexsha for c in repo.iter_commits(remote_branch)] + commits = scanner._get_commits_of_git_tree( + from_=None, to_=remote_branch, tree=module_tree + ) + for commit in commits: + self.assertIn(commit, all_commits) def test_odoo_module(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - remote_branch = f"origin/{branch}" - module = self._settings["addon"] - module_tree = repo.tree(remote_branch) / module - self.assertTrue(scanner._odoo_module(module_tree)) + with scanner.repo() as repo: + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + self.assertTrue(scanner._odoo_module(module_tree)) def test_manifest_exists(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - remote_branch = f"origin/{branch}" - # Check module tree: OK - module = self._settings["addon"] - module_tree = repo.tree(remote_branch) / module - self.assertTrue(scanner._manifest_exists(module_tree)) - # Check repository root tree: KO - self.assertFalse(scanner._manifest_exists(repo.tree(remote_branch))) + with scanner.repo() as repo: + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + # Check module tree: OK + module = self._settings["addon"] + module_tree = repo.tree(remote_branch) / module + self.assertTrue(scanner._manifest_exists(module_tree)) + # Check repository root tree: KO + self.assertFalse(scanner._manifest_exists(repo.tree(remote_branch))) def test_get_subtree(self): scanner = self._init_scanner() scanner.scan() - repo = scanner.repo - branch = self._settings["branch1"] - remote_branch = f"origin/{branch}" - module = self._settings["addon"] - # Module/folder exists: OK - self.assertTrue(scanner._get_subtree(repo.tree(remote_branch), module)) - # Module/folder doesn't exist: KO - self.assertFalse(scanner._get_subtree(repo.tree(remote_branch), "none")) + with scanner.repo() as repo: + branch = self._settings["branch1"] + remote_branch = f"origin/{branch}" + module = self._settings["addon"] + # Module/folder exists: OK + self.assertTrue(scanner._get_subtree(repo.tree(remote_branch), module)) + # Module/folder doesn't exist: KO + self.assertFalse(scanner._get_subtree(repo.tree(remote_branch), "none")) def test_workaround_fs_errors(self): scanner = self._init_scanner( diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index 2aa4d95b..ae579000 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -77,27 +77,34 @@ def test_get_repo_last_scanned_commit(self): self.assertFalse(scanner._get_repo_last_scanned_commit(repo_branch_id)) # Launch the scan and check again scanner.scan() - last_fetched_commit = scanner._get_last_fetched_commit(self.branch.name) - last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) - self.assertEqual(last_fetched_commit, last_scanned_commit) + with scanner.repo() as repo: + last_fetched_commit = scanner._get_last_fetched_commit( + repo, self.branch.name + ) + last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) + self.assertEqual(last_fetched_commit, last_scanned_commit) def test_scan_addons_path(self): scanner = self._init_scanner() scanner._clone() - scanner._checkout_branch(self.branch.name) - repo_id = scanner._get_odoo_repository_id() - branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) - repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) - last_fetched_commit = scanner._get_last_fetched_commit(self.branch.name) - last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) - # Scan the addons_path (root of the repository here) - modules_scanned = scanner._scan_addons_path( - scanner.addons_paths_data[0], - self.branch.name, - repo_branch_id, - last_fetched_commit, - last_scanned_commit, - ) + with scanner.repo() as repo: + scanner._checkout_branch(repo, self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + last_fetched_commit = scanner._get_last_fetched_commit( + repo, self.branch.name + ) + last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) + # Scan the addons_path (root of the repository here) + modules_scanned = scanner._scan_addons_path( + repo, + scanner.addons_paths_data[0], + self.branch.name, + repo_branch_id, + last_fetched_commit, + last_scanned_commit, + ) module = self._settings["addon"] self.assertIn(module, modules_scanned) self.assertTrue(modules_scanned[module]) @@ -105,25 +112,27 @@ def test_scan_addons_path(self): def test_scan_module(self): scanner = self._init_scanner() scanner._clone() - scanner._checkout_branch(self.branch.name) - repo_id = scanner._get_odoo_repository_id() - branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) - repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) - module_path = self._settings["addon"] - remote_branch = f"origin/{self.branch.name}" - module_tree = scanner.repo.tree(remote_branch) / module_path - last_module_commit = scanner._get_last_commit_of_git_tree( - remote_branch, module_tree - ) - # Scan module - addons_path_data = scanner.addons_paths_data[0] - data = scanner._scan_module( - self.branch.name, - repo_branch_id, - module_path, - last_module_commit, - addons_path_data, - ) + with scanner.repo() as repo: + scanner._checkout_branch(repo, self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + module_path = self._settings["addon"] + remote_branch = f"origin/{self.branch.name}" + module_tree = repo.tree(remote_branch) / module_path + last_module_commit = scanner._get_last_commit_of_git_tree( + remote_branch, module_tree + ) + # Scan module + addons_path_data = scanner.addons_paths_data[0] + data = scanner._scan_module( + repo, + self.branch.name, + repo_branch_id, + module_path, + last_module_commit, + addons_path_data, + ) self.assertTrue(data) self.assertTrue(data["code"]) self.assertTrue(data["manifest"]) @@ -138,24 +147,26 @@ def test_scan_module(self): def test_push_scanned_data(self): scanner = self._init_scanner() scanner._clone() - scanner._checkout_branch(self.branch.name) - repo_id = scanner._get_odoo_repository_id() - branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) - repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) - module = self._settings["addon"] - remote_branch = f"origin/{self.branch.name}" - module_tree = scanner.repo.tree(remote_branch) / module - last_module_commit = scanner._get_last_commit_of_git_tree( - remote_branch, module_tree - ) - addons_path_data = scanner.addons_paths_data[0] - data = scanner._scan_module( - self.branch.name, - repo_branch_id, - module, - last_module_commit, - addons_path_data, - ) + with scanner.repo() as repo: + scanner._checkout_branch(repo, self.branch.name) + repo_id = scanner._get_odoo_repository_id() + branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) + repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + module = self._settings["addon"] + remote_branch = f"origin/{self.branch.name}" + module_tree = repo.tree(remote_branch) / module + last_module_commit = scanner._get_last_commit_of_git_tree( + remote_branch, module_tree + ) + addons_path_data = scanner.addons_paths_data[0] + data = scanner._scan_module( + repo, + self.branch.name, + repo_branch_id, + module, + last_module_commit, + addons_path_data, + ) # Push scanned data module_branch = scanner._push_scanned_data(repo_branch_id, module, data) self.assertEqual(module_branch.module_id.name, module) @@ -186,21 +197,23 @@ def test_update_last_scanned_commit(self): branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) - last_repo_commit = scanner._get_last_fetched_commit(self.branch.name) - self.assertFalse(repo_branch.last_scanned_commit) - scanner._update_last_scanned_commit(repo_branch_id, last_repo_commit) - self.assertEqual(repo_branch.last_scanned_commit, last_repo_commit) + with scanner.repo() as repo: + last_repo_commit = scanner._get_last_fetched_commit(repo, self.branch.name) + self.assertFalse(repo_branch.last_scanned_commit) + scanner._update_last_scanned_commit(repo_branch_id, last_repo_commit) + self.assertEqual(repo_branch.last_scanned_commit, last_repo_commit) def test_scan_branch(self): scanner = self._init_scanner() scanner._clone() repo_id = scanner._get_odoo_repository_id() - # First scan: new commits detected - res = scanner._scan_branch(repo_id, self.branch.name) - self.assertTrue(res) - # Second scan: no new commits to scan - res = scanner._scan_branch(repo_id, self.branch.name) - self.assertFalse(res) + with scanner.repo() as repo: + # First scan: new commits detected + res = scanner._scan_branch(repo, repo_id, self.branch.name) + self.assertTrue(res) + # Second scan: no new commits to scan + res = scanner._scan_branch(repo, repo_id, self.branch.name) + self.assertFalse(res) def test_workaround_fs_errors(self): scanner = self._init_scanner(workaround_fs_errors=True) From 8f8a7929753ef8e2afc6147310dd5cbff7342fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 26 Aug 2024 11:14:19 +0200 Subject: [PATCH 060/160] odoo_repository: use 'queue_job__no_delay' ctx key in tests --- odoo_repository/tests/test_odoo_repository_scan.py | 2 +- odoo_repository/tests/test_sync_node.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 8f143c00..30e7f0fa 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -11,7 +11,7 @@ def test_check_config(self): def test_action_scan(self): module = self.env["odoo.module"].search([("name", "=", self.module_name)]) self.assertFalse(module) - self.odoo_repository.with_context(test_queue_job_no_delay=True).action_scan( + self.odoo_repository.with_context(queue_job__no_delay=True).action_scan( [self.branch.name] ) # Check module technical name diff --git a/odoo_repository/tests/test_sync_node.py b/odoo_repository/tests/test_sync_node.py index 0de24edb..40051027 100644 --- a/odoo_repository/tests/test_sync_node.py +++ b/odoo_repository/tests/test_sync_node.py @@ -7,7 +7,7 @@ class TestSyncNode(Common): def test_sync_node(self): # Scan a repository - self.odoo_repository.with_context(test_queue_job_no_delay=True).action_scan( + self.odoo_repository.with_context(queue_job__no_delay=True).action_scan( [self.branch.name] ) # Check data to sync From c65911ed13b5ea970327558cdf12f59b3c3324df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 28 Aug 2024 11:05:05 +0200 Subject: [PATCH 061/160] BaseScanner: revert changes in '_fetch' Revert partially commit 1a01869fc557055665342c81225eade35da93b48 ("always use remote URL provided by Odoo"). Reason: using the remote URL directly when fetching (instead of the pre-configured remote) is updating the .git/config file automatically to add a new remote, generating in turn a 'chmod' by git which can be forbidden on some mounted file-systems. As the previous change was to avoid such issue, there is no reason to keep this change, making also the code simpler. --- odoo_repository/lib/scanner.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index aeda0d68..c9aaee9b 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -244,29 +244,13 @@ def _fetch(self, repo): branches_fetched = [] for branch in self.branches: # Do not block the process if the branch doesn't exist on this repo - refs_heads_branch = f"refs/heads/{branch}" try: with self._get_git_env() as git_env: with repo.git.custom_environment(**git_env): - # Make sure to use up-to-date `clone_url` when fetching - # repository (e.g. it could have been cloned without a - # OAuth token at first, and one could have been set - # later on). - # By doing so we are not forced to store the remote URL - # in the configuration file that is triggering a 'chmod' - # command by git (to protect sensitive data) and such - # command could not work on some mounted file systems. - repo.git.fetch( - self.clone_url, - f"{refs_heads_branch}:origin/{branch}", - "--update-head-ok", - # Increase performance - "--filter=blob:none", - ) + repo.remotes.origin.fetch(branch) except git.exc.GitCommandError as exc: _logger.error(exc) - branch_not_find_error = f"couldn't find remote ref {refs_heads_branch}" - if branch_not_find_error in str(exc): + if "couldn't find remote ref" in str(exc): _logger.info( "Couldn't find remote branch %s, skipping.", self.full_name ) From f8cad90e13cafe5ff4fbd3b902ddc625ffe267fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 12 Sep 2024 10:28:47 +0200 Subject: [PATCH 062/160] BaseScanner: fix 'scan' method ('res' not defined) --- odoo_repository/lib/scanner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index c9aaee9b..44cd9e9b 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -84,6 +84,7 @@ def __init__( self.workaround_fs_errors = workaround_fs_errors def scan(self, fetch=True): + res = True self._apply_git_global_config() # Clone or update the repository if not self.is_cloned: From 009f0b00e13b2a18693188500f70365c1e9434a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 18 Sep 2024 11:31:56 +0200 Subject: [PATCH 063/160] odoo_repository: add odoo/design-themes repository --- odoo_repository/data/odoo_repository.xml | 19 ++++++++++++++++++- .../data/odoo_repository_addons_path.xml | 8 ++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml index 98a07303..99da5c98 100644 --- a/odoo_repository/data/odoo_repository.xml +++ b/odoo_repository/data/odoo_repository.xml @@ -24,7 +24,7 @@ enterprise - + https://github.com/odoo/enterprise https://github.com/odoo/enterprise @@ -39,4 +39,21 @@ /> + + + design-themes + + https://github.com/odoo/design-themes + https://github.com/odoo/design-themes + github + + + diff --git a/odoo_repository/data/odoo_repository_addons_path.xml b/odoo_repository/data/odoo_repository_addons_path.xml index ee816486..1971cca8 100644 --- a/odoo_repository/data/odoo_repository_addons_path.xml +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -19,6 +19,14 @@ + + . + + + Date: Wed, 18 Sep 2024 13:07:24 +0200 Subject: [PATCH 064/160] BaseScanner: fix '_get_last_fetched_commit' and '_checkout_branch' We could get a local branch named `origin/17.0` colliding with the real remote branch. In that case git doesn't complain but display a warning: warning: refname 'origin/17.0' is ambiguous. To get the last fetched commit of a remote branch better to prefix the refname with 'remotes/'. And to checkout a remote branch, limit the refs lookup to the origin remote. --- odoo_repository/lib/scanner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 44cd9e9b..6c422d4f 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -272,11 +272,11 @@ def _checkout_branch(self, repo, branch): # Ensure to clean up the repository before a checkout repo.git.reset("--hard") repo.git.clean("-xdf") - repo.refs[f"origin/{branch}"].checkout() + repo.remotes.origin.refs[f"{branch}"].checkout() def _get_last_fetched_commit(self, repo, branch): """Return the last fetched commit for the given `branch`.""" - return repo.rev_parse(f"origin/{branch}").hexsha + return repo.rev_parse(f"remotes/origin/{branch}").hexsha def _get_module_paths(self, repo, relative_path, branch): """Return modules available in `branch`. From d19a00e91259192e973a4a762fa2ceae949e3829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 18 Sep 2024 16:52:25 +0200 Subject: [PATCH 065/160] BaseScanner: fix handling of ambiguous branches --- odoo_repository/lib/scanner.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 6c422d4f..a3256e91 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -272,7 +272,7 @@ def _checkout_branch(self, repo, branch): # Ensure to clean up the repository before a checkout repo.git.reset("--hard") repo.git.clean("-xdf") - repo.remotes.origin.refs[f"{branch}"].checkout() + repo.git.checkout(f"remotes/origin/{branch}") def _get_last_fetched_commit(self, repo, branch): """Return the last fetched commit for the given `branch`.""" @@ -288,12 +288,15 @@ def _get_module_paths(self, repo, relative_path, branch): [dir_ for dir_ in relative_path.split("/") if dir_ and dir_ != "."] ) # No from_commit means first scan: return all available modules - branch_commit = repo.refs[f"origin/{branch}"].commit + branch_commit = repo.remotes.origin.refs[branch].commit addons_trees = branch_commit.tree.trees if relative_tree_path: addons_trees = (branch_commit.tree / relative_tree_path).trees module_paths = [ - (tree.path, self._get_last_commit_of_git_tree(f"origin/{branch}", tree)) + ( + tree.path, + self._get_last_commit_of_git_tree(f"remotes/origin/{branch}", tree), + ) for tree in addons_trees if self._odoo_module(tree) ] @@ -346,7 +349,9 @@ def _get_module_paths_updated( # FIXME: should we return pathlib.Path objects? ( tree.path, - self._get_last_commit_of_git_tree(f"origin/{branch}", tree), + self._get_last_commit_of_git_tree( + f"remotes/origin/{branch}", tree + ), ) ) else: From 18fe2d0d48704b64f433d6105a146ddefa71ed1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 20 Sep 2024 09:41:30 +0200 Subject: [PATCH 066/160] BaseScanner: fix _checkout_branch Avoid the following error on checkout: git.exc.GitCommandError: Cmd('git') failed due to: exit code(1) cmdline: git checkout remotes/origin/14.0 stderr: 'error: The following untracked working tree files would be overwritten by checkout: [...] Please move or remove them before you switch branches. Aborting' Could be because to ~/.gitignore config and actual files committed in different branches? --- odoo_repository/lib/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index a3256e91..6cd4b47c 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -272,7 +272,7 @@ def _checkout_branch(self, repo, branch): # Ensure to clean up the repository before a checkout repo.git.reset("--hard") repo.git.clean("-xdf") - repo.git.checkout(f"remotes/origin/{branch}") + repo.git.checkout("-f", f"remotes/origin/{branch}") def _get_last_fetched_commit(self, repo, branch): """Return the last fetched commit for the given `branch`.""" From 407684ffe89f8ed70c027cfc00adc04625761bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 20 Sep 2024 12:15:55 +0200 Subject: [PATCH 067/160] odoo_repository: imp. 'odoo.module.branch' tree view --- odoo_repository/views/odoo_module_branch.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index dfa41181..036442bf 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -115,8 +115,8 @@ - - + + @@ -136,10 +136,10 @@ - - - - + + + + From 2921858442e14e2ee5dc7ecb018b930e36986a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 20 Sep 2024 17:14:30 +0200 Subject: [PATCH 068/160] BaseScanner: fix _checkout_branch Remove the `.git/index.lock` file before checkout to ensure we can recover from a previous failed operation (`git` process killed). --- odoo_repository/lib/scanner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 6cd4b47c..ef141039 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -270,6 +270,9 @@ def _branch_exists(self, repo, branch): def _checkout_branch(self, repo, branch): # Ensure to clean up the repository before a checkout + index_lock_path = pathlib.Path(repo.common_dir).joinpath("index.lock") + if index_lock_path.exists(): + index_lock_path.unlink() repo.git.reset("--hard") repo.git.clean("-xdf") repo.git.checkout("-f", f"remotes/origin/{branch}") From 725c58c08d31521b4fda41af502d885a4951b0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 22 Sep 2024 17:34:50 +0200 Subject: [PATCH 069/160] odoo_repository: split RepositoryScanner jobs in smaller jobs RepositoryScanner jobs could be quite long to execute on a repository (like 'odoo/odoo'), and depending on your Odoo config or your monitoring tools, such job could get killed before it ends up its work. While there is no issue to get the RepositoryScanner job killed and restarted later, it'll re-analyze the branch to scan to detect again the modules it has to scan, and this process itself could take ages before it re-starts the scan of the modules themselves. This commit is splitting these two tasks: - one job to detect modules to scan for a given repository/branch - one job created for each module to scan All of these jobs are depending on each other so we ensure that they are run sequentially (one branch after another, and one module after another) so only one job is running for a given repository at a time. We also ensure that no more than one job can be spawned by the user from UI. This results in a higher number of jobs, but scanning can be recovered easily. --- odoo_repository/data/queue_job.xml | 27 ++- odoo_repository/lib/scanner.py | 180 +++++++++--------- odoo_repository/models/odoo_repository.py | 173 ++++++++++++++--- .../models/odoo_repository_branch.py | 13 +- odoo_repository/tests/test_base_scanner.py | 41 ++-- .../tests/test_repository_scanner.py | 69 ++++--- 6 files changed, 317 insertions(+), 186 deletions(-) diff --git a/odoo_repository/data/queue_job.xml b/odoo_repository/data/queue_job.xml index 1bfb912c..21292108 100644 --- a/odoo_repository/data/queue_job.xml +++ b/odoo_repository/data/queue_job.xml @@ -8,9 +8,32 @@ - + + + _detect_modules_to_scan_on_branch + + + + + - _scan_branch + _scan_module_on_branch + + + + + + + _update_last_scanned_commit diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index ef141039..4447e360 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -83,7 +83,7 @@ def __init__( self.token = token self.workaround_fs_errors = workaround_fs_errors - def scan(self, fetch=True): + def sync(self, fetch=True): res = True self._apply_git_global_config() # Clone or update the repository @@ -282,28 +282,18 @@ def _get_last_fetched_commit(self, repo, branch): return repo.rev_parse(f"remotes/origin/{branch}").hexsha def _get_module_paths(self, repo, relative_path, branch): - """Return modules available in `branch`. - - It returns a list of tuples `[(module, last_commit), ...]`. - """ + """Return the list of modules available in `branch`.""" # Clean up 'relative_path' to make it compatible with 'git.Tree' object relative_tree_path = "/".join( [dir_ for dir_ in relative_path.split("/") if dir_ and dir_ != "."] ) - # No from_commit means first scan: return all available modules + # Return all available modules from 'relative_tree_path' branch_commit = repo.remotes.origin.refs[branch].commit addons_trees = branch_commit.tree.trees if relative_tree_path: addons_trees = (branch_commit.tree / relative_tree_path).trees - module_paths = [ - ( - tree.path, - self._get_last_commit_of_git_tree(f"remotes/origin/{branch}", tree), - ) - for tree in addons_trees - if self._odoo_module(tree) - ] - return module_paths + module_paths = [tree.path for tree in addons_trees if self._odoo_module(tree)] + return sorted(module_paths) def _get_module_paths_updated( self, @@ -315,8 +305,7 @@ def _get_module_paths_updated( ): """Return modules updated between `from_commit` and `to_commit`. - It returns a list of tuples `[(module, last_commit), ...]`. - If a module has been removed, the tuple returned will be `(module, None)`. + It returns a list of modules. """ # Clean up 'relative_path' to make it compatible with 'git.Tree' object relative_tree_path = "/".join( @@ -325,7 +314,7 @@ def _get_module_paths_updated( module_paths = set() # Same commits: nothing has changed if from_commit == to_commit: - return module_paths + return list(module_paths) # Get only modules updated between the two commits from_commit = repo.commit(from_commit) to_commit = repo.commit(to_commit) @@ -348,21 +337,13 @@ def _get_module_paths_updated( if tree: # Module still exists if self._odoo_module(tree): - module_paths.add( - # FIXME: should we return pathlib.Path objects? - ( - tree.path, - self._get_last_commit_of_git_tree( - f"remotes/origin/{branch}", tree - ), - ) - ) + module_paths.add(tree.path) else: # Module removed tree = self._get_subtree(from_commit.tree, str(module_path)) if self._odoo_module(tree): - module_paths.add((tree.path, None)) - return module_paths + module_paths.add(tree.path) + return sorted(module_paths) def _filter_file_path(self, path): for ext in (".po", ".pot", ".rst", ".html"): @@ -446,7 +427,7 @@ def __init__( def scan(self): # Clone/fetch has been done during the repository scan, the migration # scan will be processed on the current history of commits - res = super().scan(fetch=False) + res = self.sync(fetch=False) # 'super()' could return False if the branch to scan doesn't exist, # there is nothing to scan then. if not res: @@ -463,7 +444,7 @@ def _scan_migration_path(self, repo, source_branch, target_branch): repo_source_commit = self._get_last_fetched_commit(repo, source_branch) repo_target_commit = self._get_last_fetched_commit(repo, target_branch) modules = self._get_module_paths(repo, ".", source_branch) - for module, __ in modules: + for module in modules: if self._is_module_blacklisted(module): _logger.info( "%s: '%s' is blacklisted (no migration scan)", @@ -697,7 +678,7 @@ def __init__( org: str, name: str, clone_url: str, - branches: list, + branch: str, addons_paths_data: list, repositories_path: str = None, repo_type: str = None, @@ -709,118 +690,131 @@ def __init__( org, name, clone_url, - branches, + [branch], repositories_path, repo_type, ssh_key, token, workaround_fs_errors, ) + self.branch = branch self.addons_paths_data = addons_paths_data - def scan(self): - res = super().scan() + def detect_modules_to_scan(self): + res = self.sync() # 'super()' could return False if the branch to scan doesn't exist, # there is nothing to scan then. if not res: - return False + return {} repo_id = self._get_odoo_repository_id() - branches_scanned = {} with self.repo() as repo: - for branch in self.branches: - branches_scanned[branch] = self._scan_branch(repo, repo_id, branch) - return res + return self._detect_modules_to_scan(repo, repo_id) - def _scan_branch(self, repo, repo_id, branch): - if not self._branch_exists(repo, branch): + def _detect_modules_to_scan(self, repo, repo_id): + if not self._branch_exists(repo, self.branch): return - branch_id = self._get_odoo_branch_id(repo_id, branch) + branch_id = self._get_odoo_branch_id(repo_id, self.branch) repo_branch_id = self._create_odoo_repository_branch(repo_id, branch_id) - last_fetched_commit = self._get_last_fetched_commit(repo, branch) + last_fetched_commit = self._get_last_fetched_commit(repo, self.branch) last_scanned_commit = self._get_repo_last_scanned_commit(repo_branch_id) + data = { + "repo_branch_id": repo_branch_id, + "last_fetched_commit": last_fetched_commit, + "last_scanned_commit": last_scanned_commit, + "addons_paths": {}, + } if last_fetched_commit != last_scanned_commit: - # Checkout the source branch to: - # - get the last commit of a module working tree - # - perform module code analysis - self._checkout_branch(repo, branch) + # Checkout the source branch to get the last commit of a module working tree + self._checkout_branch(repo, self.branch) # Scan relevant subfolders of the repository for addons_path_data in self.addons_paths_data: - self._scan_addons_path( - repo, - addons_path_data, - branch, - repo_branch_id, - last_fetched_commit, - last_scanned_commit, - ) - # Flag this repository/branch as scanned - self._update_last_scanned_commit(repo_branch_id, last_fetched_commit) - return True - return False + addons_path = addons_path_data["relative_path"] + data["addons_paths"][addons_path] = { + "specs": addons_path_data, + "modules_to_scan": self._detect_modules_to_scan_in_addons_path( + repo, + addons_path, + repo_branch_id, + last_fetched_commit, + last_scanned_commit, + ), + } + return data - def _scan_addons_path( + def _detect_modules_to_scan_in_addons_path( self, repo, - addons_path_data, - branch, + addons_path, repo_branch_id, last_fetched_commit, last_scanned_commit, ): if not last_scanned_commit: - module_paths = sorted( - self._get_module_paths(repo, addons_path_data["relative_path"], branch) + # Get all module paths + modules_to_scan = sorted( + self._get_module_paths(repo, addons_path, self.branch) ) else: # Get module paths updated since the last scanned commit - module_paths = sorted( - self._get_module_paths_updated( - repo, - addons_path_data["relative_path"], - from_commit=last_scanned_commit, - to_commit=last_fetched_commit, - branch=branch, - ) + modules_to_scan = self._get_module_paths_updated( + repo, + addons_path, + from_commit=last_scanned_commit, + to_commit=last_fetched_commit, + branch=self.branch, ) extra_log = "" - if addons_path_data["relative_path"] != ".": - extra_log = f" in {addons_path_data['relative_path']}" + if addons_path != ".": + extra_log = f" in {addons_path}" _logger.info( "%s: %s module(s) updated on %s" + extra_log, self.full_name, - len(module_paths), - branch, + len(modules_to_scan), + self.branch, ) - # Scan each module - modules_scanned = {} - for module_path, last_module_commit in module_paths: - self._scan_module( + return modules_to_scan + + def scan_module(self, module_path, specs): + self._apply_git_global_config() + repo_id = self._get_odoo_repository_id() + branch_id = self._get_odoo_branch_id(repo_id, self.branch) + repo_branch_id = self._create_odoo_repository_branch(repo_id, branch_id) + with self.repo() as repo: + # Checkout the source branch to perform module code analysis + branch_commit = self._get_last_fetched_commit(repo, self.branch) + if repo.head.commit.hexsha != branch_commit: + self._checkout_branch(repo, self.branch) + # Get last commit of 'module_path' + module_tree = self._get_subtree( + repo.commit(branch_commit).tree, module_path + ) + last_module_commit = ( + self._get_last_commit_of_git_tree(f"{branch_commit}", module_tree) + if module_tree + else None + ) + return self._scan_module( repo, - branch, repo_branch_id, module_path, last_module_commit, - addons_path_data, + specs, ) - module = module_path.split("/")[-1] - modules_scanned[module] = True - return modules_scanned def _scan_module( self, repo, - branch, repo_branch_id, module_path, last_module_commit, - addons_path_data, + specs, ): module = module_path.split("/")[-1] if self._is_module_blacklisted(module): _logger.info( "%s#%s: '%s' is blacklisted (no scan)", self.full_name, - branch, + self.branch, module_path, ) return @@ -839,13 +833,13 @@ def _scan_module( _logger.info( "%s#%s: scan '%s' ", self.full_name, - branch, + self.branch, module_path, ) data = self._run_module_code_analysis( repo, module_path, - branch, + self.branch, last_module_scanned_commit, last_module_commit, ) @@ -853,11 +847,11 @@ def _scan_module( _logger.info( "%s#%s: '%s' removed", self.full_name, - branch, + self.branch, module_path, ) # Insert all flags 'is_standard', 'is_enterprise', etc - data.update(addons_path_data) + data.update(specs) # Set the last fetched commit as last scanned commit data["last_scanned_commit"] = last_module_commit self._push_scanned_data(repo_branch_id, module, data) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 5bff4162..f3c3914a 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json +import logging import os import pathlib from urllib.parse import urljoin @@ -17,6 +18,8 @@ from ..utils.scanner import RepositoryScannerOdooEnv +_logger = logging.getLogger(__name__) + class OdooRepository(models.Model): _name = "odoo.repository" @@ -154,7 +157,7 @@ def cron_scanner(self, branches=None, force=False): if not branches: branches = self._get_odoo_branches_to_clone().mapped("name") for repo in repositories: - repo.action_scan(branches=branches, force=force) + repo.action_scan(branches=branches, force=force, raise_exc=False) def _check_config(self): # Check the configuration of repositories folder @@ -170,10 +173,44 @@ def _check_config(self): # Ensure the folder exists pathlib.Path(repositories_path).mkdir(parents=True, exist_ok=True) - def action_scan(self, branches=None, force=False): + def _check_existing_jobs(self, raise_exc=True): + """Check if a scan is already triggered for this repository.""" + self.ensure_one() + existing_job = ( + self.env["queue.job"] + .sudo() + .search( + [ + ("model_name", "=", self._name), + ("records", "ilike", f'%"ids": [{self.id}]%'), + ( + "state", + "in", + [ + "wait_dependencies", + "pending", + "enqueued", + "started", + ], + ), + ], + limit=1, + ) + ) + if existing_job: + msg = _("A scan is already ongoing for repository %s") % self.display_name + if raise_exc: + raise UserError(msg) + _logger.warning(msg) + return True + return False + + def action_scan(self, branches=None, force=False, raise_exc=True): """Scan the whole repository.""" self._check_config() for rec in self: + if rec._check_existing_jobs(raise_exc=raise_exc): + continue # Copy `branches` list to not override initial values branches_ = branches and branches[:] or [] if not rec.to_scan: @@ -190,10 +227,111 @@ def action_scan(self, branches=None, force=False): rec._reset_scanned_commits(branches_) # Scan repository branches sequentially as they need to be checked out # to perform the analysis - jobs = rec._create_jobs(branches_) - chain(*jobs).delay() + # Here the launched job is responsible to: + # 1) detect modules updated on the first branch + # 2) spawn jobs to scan each module on that branch + # 3) spawn a job to update the last scanned commit of the repo/branch + # 4) spawn the next job responsible to detect modules updated + # on the next branch + branch = branches_[0] + next_branches = branches_[1:] + job = rec._create_job_detect_modules_to_scan_on_branch( + branch, next_branches, branches_ + ) + job.delay() return True + def _create_job_detect_modules_to_scan_on_branch( + self, branch, next_branches, all_branches + ): + self.ensure_one() + delayable = self.delayable( + description=f"Detect modules to scan in {self.display_name}#{branch}", + identity_key=identity_exact, + ) + return delayable._detect_modules_to_scan_on_branch( + branch, next_branches, all_branches + ) + + def _detect_modules_to_scan_on_branch(self, branch, next_branches, all_branches): + """Detect the modules to scan on `branch`. + + It will spawn a job for each module to scan, and two other jobs to: + - update the last scanned commit on the repo/branch + - scan the next branch (so each branch is scanned in cascade) + + This ensure to scan different branches sequentially for a given repository. + """ + try: + # Get the list of modules updated since last scan + params = self._prepare_scanner_parameters(branch) + scanner = RepositoryScannerOdooEnv(**params) + data = scanner.detect_modules_to_scan() + # Prepare all subsequent jobs based on modules to scan + jobs = self._create_subsequent_jobs( + branch, next_branches, all_branches, data + ) + # Chain them altogether + chain(*jobs).delay() + except Exception as exc: + raise RetryableJobError("Scanner error") from exc + + def _create_subsequent_jobs(self, branch, next_branches, all_branches, data): + jobs = [] + # Spawn one job per module to scan + for data_ in data.get("addons_paths", {}).values(): + for module_path in data_["modules_to_scan"]: + job = self._create_job_scan_module_on_branch( + branch, module_path, data_["specs"] + ) + jobs.append(job) + # + another one to update the last scanned commit of the repository + if data.get("repo_branch_id"): + job = self._create_job_update_last_scanned_commit( + data["repo_branch_id"], + data["last_fetched_commit"], + ) + jobs.append(job) + # + another one to detect modules to scan on the next branch + branch = next_branches and next_branches[0] + next_branches = next_branches[1:] + if branch: + jobs.append( + self._create_job_detect_modules_to_scan_on_branch( + branch, next_branches, all_branches + ) + ) + return jobs + + def _create_job_scan_module_on_branch(self, branch, module_path, specs): + self.ensure_one() + delayable = self.delayable( + description=f"Scan {self.display_name}#{branch} - {module_path}", + identity_key=identity_exact, + ) + return delayable._scan_module_on_branch(branch, module_path, specs) + + def _scan_module_on_branch(self, branch, module_path, specs): + """Scan `module_path` from `branch`.""" + try: + params = self._prepare_scanner_parameters(branch) + scanner = RepositoryScannerOdooEnv(**params) + return scanner.scan_module(module_path, specs) + except Exception as exc: + raise RetryableJobError("Scanner error") from exc + + def _create_job_update_last_scanned_commit( + self, repo_branch_id, last_scanned_commit, last_scan=False + ): + self.ensure_one() + repo_branch_model = self.env["odoo.repository.branch"] + repo_branch = repo_branch_model.browse(repo_branch_id).exists() + delayable = repo_branch.delayable( + description=f"Update last scanned commit of {repo_branch.display_name}", + identity_key=identity_exact, + ) + return delayable._update_last_scanned_commit(last_scanned_commit) + def _reset_scanned_commits(self, branches=None): """Reset the scanned commits. @@ -211,27 +349,6 @@ def _reset_scanned_commits(self, branches=None): branches_.write({"last_scanned_commit": False}) branches_.module_ids.sudo().write({"last_scanned_commit": False}) - def _create_jobs(self, branches): - self.ensure_one() - jobs = [] - for branch in branches: - delayable = self.delayable( - description=f"Scan {self.display_name}#{branch}", - identity_key=identity_exact, - ) - job = delayable._scan_branch(branch) - jobs.append(job) - return jobs - - def _scan_branch(self, branch): - """Scan a repository branch""" - try: - params = self._prepare_scanner_parameters(branch) - scanner = RepositoryScannerOdooEnv(**params) - return scanner.scan() - except Exception as exc: - raise RetryableJobError("Scanner error") from exc - def _get_token(self): """Return the first available token found for this repository. @@ -254,7 +371,7 @@ def _prepare_scanner_parameters(self, branch): "org": self.org_id.name, "name": self.name, "clone_url": self.clone_url, - "branches": [branch], + "branch": branch, "addons_paths_data": self.addons_path_ids.read( [ "relative_path", @@ -273,14 +390,14 @@ def _prepare_scanner_parameters(self, branch): "env": self.env, } - def action_force_scan(self, branches=None): + def action_force_scan(self, branches=None, raise_exc=True): """Force the scan of the repositories. It will restart the scan without considering the last scanned commit, overriding already collected module data if any. """ self.ensure_one() - return self.action_scan(branches=branches, force=True) + return self.action_scan(branches=branches, force=True, raise_exc=raise_exc) @api.model def cron_fetch_data(self, branches=None, force=False): diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index 344f8baf..f6871899 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -52,19 +52,19 @@ def _compute_active(self): for rec in self: rec.active = all((rec.repository_id.active, rec.branch_id.active)) - def action_scan(self, force=False): + def action_scan(self, force=False, raise_exc=True): """Scan the repository/branch.""" return self.repository_id.action_scan( - branches=self.branch_id.mapped("name"), force=force + branches=self.branch_id.mapped("name"), force=force, raise_exc=raise_exc ) - def action_force_scan(self): + def action_force_scan(self, raise_exc=True): """Force the scan of the repository/branch. It will restart the scan without considering the last scanned commit, overriding already collected module data if any. """ - return self.action_scan(force=True) + return self.action_scan(force=True, raise_exc=raise_exc) def _to_dict(self): """Convert branch repository data to a dictionary.""" @@ -78,3 +78,8 @@ def _to_dict(self): "branch": self.branch_id.name, "last_scanned_commit": self.last_scanned_commit, } + + def _update_last_scanned_commit(self, last_scanned_commit): + """Update the last scanned commit. Called by job.""" + self.ensure_one() + self.last_scanned_commit = last_scanned_commit diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index 8769a436..07405e16 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -47,19 +47,19 @@ def test_clone_url_github_token(self): token_clone_url = f"https://oauth2:{token}@github.com/OCA/test" self.assertEqual(scanner.clone_url, token_clone_url) - def test_scan(self): + def test_sync(self): scanner = self._init_scanner(repositories_path=tempfile.mkdtemp()) # Clone self.assertFalse(scanner.path.exists()) self.assertFalse(scanner.is_cloned) - scanner.scan() + scanner.sync() self.assertTrue(scanner.is_cloned) # Fetch once cloned - scanner.scan() + scanner.sync() def test_branch_exists(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: self.assertTrue(scanner._branch_exists(repo, self._settings["branch1"])) self.assertTrue(scanner._branch_exists(repo, self._settings["branch2"])) @@ -67,7 +67,7 @@ def test_branch_exists(self): def test_checkout_branch(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] branch_sha = repo.refs[f"origin/{branch}"].object.hexsha @@ -77,7 +77,7 @@ def test_checkout_branch(self): def test_get_last_fetched_commit(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch1 = self._settings["branch1"] branch2 = self._settings["branch2"] @@ -97,19 +97,16 @@ def test_get_last_fetched_commit(self): def test_get_module_paths(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] module_paths = scanner._get_module_paths(repo, ".", branch) self.assertEqual(len(module_paths), 1) - self.assertEqual(len(module_paths[0]), 2) - self.assertEqual(module_paths[0][0], self._settings["addon"]) - all_commits = [c.hexsha for c in repo.iter_commits(f"origin/{branch}")] - self.assertIn(module_paths[0][1], all_commits) + self.assertEqual(module_paths[0], self._settings["addon"]) def test_get_module_paths_updated(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() branch = self._settings["branch1"] with scanner.repo() as repo: initial_commit = scanner._get_last_fetched_commit(repo, branch) @@ -125,7 +122,7 @@ def test_get_module_paths_updated(self): # Update the upstream repository with a new commit self._update_module_version_on_branch(branch, "1.0.1") # Module is now detected has updated - scanner.scan() # Fetch new commit from upstream repo + scanner.sync() # Fetch new commit from upstream repo with scanner.repo() as repo: last_commit = scanner._get_last_fetched_commit(repo, branch) module_paths = scanner._get_module_paths_updated( @@ -136,9 +133,7 @@ def test_get_module_paths_updated(self): branch=branch, ) self.assertEqual(len(module_paths), 1) - module_path = module_paths.pop() - self.assertEqual(module_path[0], self._settings["addon"]) - self.assertEqual(module_path[1], last_commit) + self.assertEqual(module_paths.pop(), self._settings["addon"]) def test_filter_file_path(self): scanner = self._init_scanner() @@ -147,7 +142,7 @@ def test_filter_file_path(self): def test_get_last_commit_of_git_tree(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] remote_branch = f"origin/{branch}" @@ -159,7 +154,7 @@ def test_get_last_commit_of_git_tree(self): def test_get_commits_of_git_tree(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] remote_branch = f"origin/{branch}" @@ -174,7 +169,7 @@ def test_get_commits_of_git_tree(self): def test_odoo_module(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] remote_branch = f"origin/{branch}" @@ -184,7 +179,7 @@ def test_odoo_module(self): def test_manifest_exists(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] remote_branch = f"origin/{branch}" @@ -197,7 +192,7 @@ def test_manifest_exists(self): def test_get_subtree(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() with scanner.repo() as repo: branch = self._settings["branch1"] remote_branch = f"origin/{branch}" @@ -215,7 +210,7 @@ def test_workaround_fs_errors(self): # Clone self.assertFalse(scanner.path.exists()) self.assertFalse(scanner.is_cloned) - scanner.scan() + scanner.sync() self.assertTrue(scanner.is_cloned) # Fetch once cloned - scanner.scan() + scanner.sync() diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index ae579000..7a1fd332 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -12,7 +12,7 @@ def _init_scanner(self, **params): "org": self.org.name, "name": self.repo_name, "clone_url": self.repo_upstream_path, - "branches": [self.branch.name], + "branch": self.branch.name, "addons_paths_data": [ { "relative_path": ".", @@ -37,9 +37,9 @@ def test_init(self): scanner.full_name, f"{self._settings['user_org']}/{self.repo_name}" ) - def test_scan(self): + def test_sync(self): scanner = self._init_scanner() - scanner.scan() + scanner.sync() def test_get_odoo_repository_id(self): scanner = self._init_scanner() @@ -73,18 +73,31 @@ def test_get_repo_last_scanned_commit(self): repo_id = scanner._get_odoo_repository_id() branch_id = scanner._get_odoo_branch_id(repo_id, self.branch.name) repo_branch_id = scanner._create_odoo_repository_branch(repo_id, branch_id) + repo_branch = self.env["odoo.repository.branch"].browse(repo_branch_id) # Nothing has been scanned until now self.assertFalse(scanner._get_repo_last_scanned_commit(repo_branch_id)) - # Launch the scan and check again - scanner.scan() + # Clone/fetch the repo + scanner.sync() with scanner.repo() as repo: last_fetched_commit = scanner._get_last_fetched_commit( repo, self.branch.name ) + # Simulate the end of scan + repo_branch.last_scanned_commit = last_fetched_commit + # Check again last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) self.assertEqual(last_fetched_commit, last_scanned_commit) - def test_scan_addons_path(self): + def test_detect_modules_to_scan(self): + scanner = self._init_scanner() + scanner._clone() + repo_id = scanner._get_odoo_repository_id() + with scanner.repo() as repo: + res = scanner._detect_modules_to_scan(repo, repo_id) + self.assertTrue(res) + self.assertIn("my_module", res["addons_paths"]["."]["modules_to_scan"]) + + def test_detect_modules_to_scan_in_addons_path(self): scanner = self._init_scanner() scanner._clone() with scanner.repo() as repo: @@ -97,17 +110,15 @@ def test_scan_addons_path(self): ) last_scanned_commit = scanner._get_repo_last_scanned_commit(repo_branch_id) # Scan the addons_path (root of the repository here) - modules_scanned = scanner._scan_addons_path( + modules_to_scan = scanner._detect_modules_to_scan_in_addons_path( repo, - scanner.addons_paths_data[0], - self.branch.name, + scanner.addons_paths_data[0]["relative_path"], repo_branch_id, last_fetched_commit, last_scanned_commit, ) module = self._settings["addon"] - self.assertIn(module, modules_scanned) - self.assertTrue(modules_scanned[module]) + self.assertIn(module, modules_to_scan) def test_scan_module(self): scanner = self._init_scanner() @@ -124,21 +135,20 @@ def test_scan_module(self): remote_branch, module_tree ) # Scan module - addons_path_data = scanner.addons_paths_data[0] + specs = scanner.addons_paths_data[0] data = scanner._scan_module( repo, - self.branch.name, repo_branch_id, module_path, last_module_commit, - addons_path_data, + specs, ) self.assertTrue(data) self.assertTrue(data["code"]) self.assertTrue(data["manifest"]) - self.assertEqual(data["is_standard"], addons_path_data["is_standard"]) - self.assertEqual(data["is_enterprise"], addons_path_data["is_enterprise"]) - self.assertEqual(data["is_community"], addons_path_data["is_community"]) + self.assertEqual(data["is_standard"], specs["is_standard"]) + self.assertEqual(data["is_enterprise"], specs["is_enterprise"]) + self.assertEqual(data["is_community"], specs["is_community"]) self.assertEqual(data["last_scanned_commit"], last_module_commit) self.assertIn("1.0.0", data["versions"]) self.assertEqual(data["versions"]["1.0.0"]["commit"], last_module_commit) @@ -158,14 +168,13 @@ def test_push_scanned_data(self): last_module_commit = scanner._get_last_commit_of_git_tree( remote_branch, module_tree ) - addons_path_data = scanner.addons_paths_data[0] + specs = scanner.addons_paths_data[0] data = scanner._scan_module( repo, - self.branch.name, repo_branch_id, module, last_module_commit, - addons_path_data, + specs, ) # Push scanned data module_branch = scanner._push_scanned_data(repo_branch_id, module, data) @@ -176,9 +185,9 @@ def test_push_scanned_data(self): [ { "repository_branch_id": repo_branch_id, - "is_standard": addons_path_data["is_standard"], - "is_enterprise": addons_path_data["is_enterprise"], - "is_community": addons_path_data["is_community"], + "is_standard": specs["is_standard"], + "is_enterprise": specs["is_enterprise"], + "is_community": specs["is_community"], "application": data["manifest"].get("application", False), "installable": data["manifest"]["installable"], "sloc_python": data["code"]["Python"], @@ -203,18 +212,6 @@ def test_update_last_scanned_commit(self): scanner._update_last_scanned_commit(repo_branch_id, last_repo_commit) self.assertEqual(repo_branch.last_scanned_commit, last_repo_commit) - def test_scan_branch(self): - scanner = self._init_scanner() - scanner._clone() - repo_id = scanner._get_odoo_repository_id() - with scanner.repo() as repo: - # First scan: new commits detected - res = scanner._scan_branch(repo, repo_id, self.branch.name) - self.assertTrue(res) - # Second scan: no new commits to scan - res = scanner._scan_branch(repo, repo_id, self.branch.name) - self.assertFalse(res) - def test_workaround_fs_errors(self): scanner = self._init_scanner(workaround_fs_errors=True) - scanner.scan() + scanner.sync() From 718a2658f7bc29b26bb5836b0d1f51fadcb43f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 23 Sep 2024 14:43:24 +0200 Subject: [PATCH 070/160] odoo_repository_migration: split MigrationScanner jobs in smaller jobs MigrationScanner jobs could be quite long to execute on some repositories, and depending on your Odoo config or your monitoring tools, such job could get killed before it ends up its work. This commit is splitting these two tasks: - one job to scan a migration path for a given repository (that will create one job per module needing a migration scan) - one job created for each module to scan Migration scan jobs depends on the last repository scan job, as we need this one to be finished to know the list of modules that would need a migration scan. --- odoo_repository/lib/scanner.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 4447e360..161ed93d 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -403,14 +403,14 @@ def __init__( org: str, name: str, clone_url: str, - migration_paths: list[tuple[str]], + migration_path: tuple[str], repositories_path: str = None, repo_type: str = None, ssh_key: str = None, token: str = None, workaround_fs_errors: bool = False, ): - branches = sorted(set(sum([tuple(mp) for mp in migration_paths], ()))) + branches = sorted(migration_path) super().__init__( org, name, @@ -422,9 +422,9 @@ def __init__( token, workaround_fs_errors, ) - self.migration_paths = migration_paths + self.migration_path = migration_path - def scan(self): + def scan(self, modules=None): # Clone/fetch has been done during the repository scan, the migration # scan will be processed on the current history of commits res = self.sync(fetch=False) @@ -432,18 +432,22 @@ def scan(self): # there is nothing to scan then. if not res: return False - for source_branch, target_branch in self.migration_paths: - with self.repo() as repo: - if self._branch_exists(repo, source_branch) and self._branch_exists( - repo, target_branch - ): - self._scan_migration_path(repo, source_branch, target_branch) + source_branch, target_branch = self.migration_path + with self.repo() as repo: + if self._branch_exists(repo, source_branch) and self._branch_exists( + repo, target_branch + ): + return self._scan_migration_path( + repo, source_branch, target_branch, modules=modules + ) return res - def _scan_migration_path(self, repo, source_branch, target_branch): + def _scan_migration_path(self, repo, source_branch, target_branch, modules=None): repo_source_commit = self._get_last_fetched_commit(repo, source_branch) repo_target_commit = self._get_last_fetched_commit(repo, target_branch) - modules = self._get_module_paths(repo, ".", source_branch) + if not modules: + modules = self._get_module_paths(repo, ".", source_branch) + res = [] for module in modules: if self._is_module_blacklisted(module): _logger.info( @@ -487,7 +491,7 @@ def _scan_migration_path(self, repo, source_branch, target_branch): data.get("last_source_scanned_commit") != module_source_commit or data.get("last_target_scanned_commit") != module_target_commit ): - self._scan_module( + scanned_data = self._scan_module( repo, module, module_branch_id, @@ -498,6 +502,8 @@ def _scan_migration_path(self, repo, source_branch, target_branch): data.get("last_source_scanned_commit"), data.get("last_target_scanned_commit"), ) + res.append(scanned_data) + return res def _scan_module( self, @@ -544,7 +550,7 @@ def _scan_module( # Mitigate "GH API rate limit exceeds" error if scan_relevant: time.sleep(4) - return True + return data def _is_scan_module_relevant( self, From d60f979d876331de03e1f4f86f26c9c570f5eb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 25 Sep 2024 12:10:38 +0200 Subject: [PATCH 071/160] BaseScanner: fix git config safe.directory --- odoo_repository/lib/scanner.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 161ed93d..41ba85ef 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -149,10 +149,11 @@ def _apply_git_global_config(self): # Avoid 'fatal: detected dubious ownership in repository' errors # when performing operations in git repositories in case they are # cloned on an mounted filesystem with specific options. - # NOTE: ensure to unset existing entry before adding one, as git doesn't - # check if an entry already exists, generating duplicates - os.system('git config --global --unset safe.directory "%s"' % (self.path)) - os.system('git config --global --add safe.directory "%s"' % (self.path)) + if self.workaround_fs_errors: + # NOTE: ensure to unset existing entry before adding one, as git doesn't + # check if an entry already exists, generating duplicates + os.system(r"git config --global --unset safe.directory '\*'") + os.system("git config --global --add safe.directory '*'") def _apply_git_config(self, repo): with repo.config_writer() as writer: From 0f675677424895ce76e0f59145e2eb8fdfb3e0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 11 Oct 2024 09:13:04 +0200 Subject: [PATCH 072/160] odoo_repository: fix _detect_modules_to_scan_on_branch if no jobs --- odoo_repository/models/odoo_repository.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index f3c3914a..ea356383 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -272,7 +272,8 @@ def _detect_modules_to_scan_on_branch(self, branch, next_branches, all_branches) branch, next_branches, all_branches, data ) # Chain them altogether - chain(*jobs).delay() + if jobs: + chain(*jobs).delay() except Exception as exc: raise RetryableJobError("Scanner error") from exc From 243844415326e4b85781797a7528eab903b190b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 17 Oct 2024 18:30:27 +0200 Subject: [PATCH 073/160] BaseScanner: improve clone IO performance The use of '--filter=blob:none' was generating more disk IO operations than without. On a network mounted volume, the performance of any git operations decreases a lot compared to a std clone (with all blobs). Also, to improve the time of a clone operation, we restrict it to a single branch. If another branch is required later, it'll be fetched automatically anyway. --- odoo_repository/lib/scanner.py | 6 ++---- odoo_repository/tests/test_base_scanner.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 41ba85ef..1626cc51 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -205,15 +205,13 @@ def _clone_params(self, **extra): params = { "url": self.clone_url, "to_path": self.path, - # NOTE: adding 'no_checkout' and 'filter=blob:none' allows fast - # cloning and reduce memory usage. Blobs will be fetched later on - # demand, once the git config to reduce memory usage is applied. "no_checkout": True, - "filter": "blob:none", # Avoid issues with file permissions # "allow_unsafe_options": True, # "multi_options": ["--config core.filemode=false"], } + if self.branches: + params["branch"] = self.branches[0] params.update(extra) return params diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index 07405e16..f7999473 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -69,7 +69,7 @@ def test_checkout_branch(self): scanner = self._init_scanner() scanner.sync() with scanner.repo() as repo: - branch = self._settings["branch1"] + branch = self._settings["branch2"] branch_sha = repo.refs[f"origin/{branch}"].object.hexsha self.assertNotEqual(repo.head.object.hexsha, branch_sha) scanner._checkout_branch(repo, branch) From 76a14a9dbcd4d421737c0f785aaa2db9b645b8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 17 Oct 2024 19:43:45 +0200 Subject: [PATCH 074/160] odoo_repository: add User and Manager user groups --- odoo_repository/__manifest__.py | 1 + odoo_repository/security/ir.model.access.csv | 50 +++++++++---------- odoo_repository/security/res_groups.xml | 25 ++++++++++ .../views/authentication_token.xml | 10 +++- odoo_repository/views/menu.xml | 3 +- odoo_repository/views/odoo_branch.xml | 4 +- odoo_repository/views/odoo_repository.xml | 8 +-- odoo_repository/views/res_config_settings.xml | 4 +- odoo_repository/views/ssh_key.xml | 10 +++- 9 files changed, 77 insertions(+), 38 deletions(-) create mode 100644 odoo_repository/security/res_groups.xml diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index e80a15ec..40e25412 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -8,6 +8,7 @@ "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/camptocamp/odoo-repository", "data": [ + "security/res_groups.xml", "security/ir.model.access.csv", "data/ir_cron.xml", "data/odoo_module.xml", diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index 77c16d40..4fda44e8 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -1,26 +1,26 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_authentication_token_user,authentication_token_user,model_authentication_token,base.group_user,0,0,0,0 -access_authentication_manager,authentication_token_manager,model_authentication_token,base.group_system,1,1,1,1 -access_ssh_key_user,ssh_key_user,model_ssh_key,base.group_user,0,0,0,0 -access_ssh_key_manager,ssh_key_manager,model_ssh_key,base.group_system,1,1,1,1 -access_odoo_author_user,odoo_author_user,model_odoo_author,base.group_user,1,0,0,0 -access_odoo_branch_user,odoo_branch_user,model_odoo_branch,base.group_user,1,0,0,0 -access_odoo_branch_manager,odoo_branch_manager,model_odoo_branch,base.group_system,1,1,1,1 -access_odoo_license_user,odoo_license_user,model_odoo_license,base.group_user,1,0,0,0 -access_odoo_maintainer_user,odoo_maintainer_user,model_odoo_maintainer,base.group_user,1,0,0,0 -access_odoo_module_user,odoo_module_user,model_odoo_module,base.group_user,1,0,0,0 -access_odoo_module_manager,odoo_module_manager,model_odoo_module,base.group_system,1,0,1,1 -access_odoo_module_category_user,odoo_module_category_user,model_odoo_module_category,base.group_user,1,0,0,0 -access_odoo_module_dev_status_user,odoo_module_dev_status_user,model_odoo_module_dev_status,base.group_user,1,0,0,0 -access_odoo_python_dependency_user,odoo_python_dependency_user,model_odoo_python_dependency,base.group_user,1,0,0,0 -access_odoo_repository_addons_path_user,odoo_repository_addons_path_user,model_odoo_repository_addons_path,base.group_user,1,0,0,0 -access_odoo_repository_addons_path_manager,odoo_repository_addons_path_manager,model_odoo_repository_addons_path,base.group_system,1,1,1,1 -access_odoo_repository_org_user,odoo_repository_org_user,model_odoo_repository_org,base.group_user,1,0,0,0 -access_odoo_repository_org_manager,odoo_repository_org_manager,model_odoo_repository_org,base.group_system,1,1,1,1 -access_odoo_repository_user,odoo_repository_user,model_odoo_repository,base.group_user,1,0,0,0 -access_odoo_repository_manager,odoo_repository_manager,model_odoo_repository,base.group_system,1,1,1,1 -access_odoo_repository_branch_user,odoo_repository_branch_user,model_odoo_repository_branch,base.group_user,1,0,0,0 -access_odoo_repository_branch_manager,odoo_repository_branch_manager,model_odoo_repository_branch,base.group_system,1,1,1,1 -access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch,base.group_user,1,0,0,0 -access_odoo_module_branch_version_user,odoo_module_branch_version_user,model_odoo_module_branch_version,base.group_user,1,0,0,0 -access_odoo_module_branch_version_manager,odoo_module_branch_version_manager,model_odoo_module_branch_version,base.group_system,1,1,1,1 +access_authentication_token_user,authentication_token_user,model_authentication_token,group_odoo_repository_user,0,0,0,0 +access_authentication_manager,authentication_token_manager,model_authentication_token,group_odoo_repository_manager,1,1,1,1 +access_ssh_key_user,ssh_key_user,model_ssh_key,group_odoo_repository_user,0,0,0,0 +access_ssh_key_manager,ssh_key_manager,model_ssh_key,group_odoo_repository_manager,1,1,1,1 +access_odoo_author_user,odoo_author_user,model_odoo_author,group_odoo_repository_user,1,0,0,0 +access_odoo_branch_user,odoo_branch_user,model_odoo_branch,group_odoo_repository_user,1,0,0,0 +access_odoo_branch_manager,odoo_branch_manager,model_odoo_branch,group_odoo_repository_manager,1,1,1,1 +access_odoo_license_user,odoo_license_user,model_odoo_license,group_odoo_repository_user,1,0,0,0 +access_odoo_maintainer_user,odoo_maintainer_user,model_odoo_maintainer,group_odoo_repository_user,1,0,0,0 +access_odoo_module_user,odoo_module_user,model_odoo_module,group_odoo_repository_user,1,0,0,0 +access_odoo_module_manager,odoo_module_manager,model_odoo_module,group_odoo_repository_manager,1,0,1,1 +access_odoo_module_category_user,odoo_module_category_user,model_odoo_module_category,group_odoo_repository_user,1,0,0,0 +access_odoo_module_dev_status_user,odoo_module_dev_status_user,model_odoo_module_dev_status,group_odoo_repository_user,1,0,0,0 +access_odoo_python_dependency_user,odoo_python_dependency_user,model_odoo_python_dependency,group_odoo_repository_user,1,0,0,0 +access_odoo_repository_addons_path_user,odoo_repository_addons_path_user,model_odoo_repository_addons_path,group_odoo_repository_user,1,0,0,0 +access_odoo_repository_addons_path_manager,odoo_repository_addons_path_manager,model_odoo_repository_addons_path,group_odoo_repository_manager,1,1,1,1 +access_odoo_repository_org_user,odoo_repository_org_user,model_odoo_repository_org,group_odoo_repository_user,1,0,0,0 +access_odoo_repository_org_manager,odoo_repository_org_manager,model_odoo_repository_org,group_odoo_repository_manager,1,1,1,1 +access_odoo_repository_user,odoo_repository_user,model_odoo_repository,group_odoo_repository_user,1,0,0,0 +access_odoo_repository_manager,odoo_repository_manager,model_odoo_repository,group_odoo_repository_manager,1,1,1,1 +access_odoo_repository_branch_user,odoo_repository_branch_user,model_odoo_repository_branch,group_odoo_repository_user,1,0,0,0 +access_odoo_repository_branch_manager,odoo_repository_branch_manager,model_odoo_repository_branch,group_odoo_repository_manager,1,1,1,1 +access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch,group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_version_user,odoo_module_branch_version_user,model_odoo_module_branch_version,group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_version_manager,odoo_module_branch_version_manager,model_odoo_module_branch_version,group_odoo_repository_manager,1,1,1,1 diff --git a/odoo_repository/security/res_groups.xml b/odoo_repository/security/res_groups.xml new file mode 100644 index 00000000..191d932f --- /dev/null +++ b/odoo_repository/security/res_groups.xml @@ -0,0 +1,25 @@ + + + + + + Odoo Repository + 50 + + + + Odoo Repository User + + + + + Odoo Repository Manager + + + + + diff --git a/odoo_repository/views/authentication_token.xml b/odoo_repository/views/authentication_token.xml index 1afb5380..8f30b01c 100644 --- a/odoo_repository/views/authentication_token.xml +++ b/odoo_repository/views/authentication_token.xml @@ -6,7 +6,10 @@ authentication.token.form authentication.token - + @@ -45,7 +48,10 @@ ir.actions.act_window authentication.token - + diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index 6f7ce5db..3085adca 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -14,7 +14,7 @@ type="object" string="Scan" class="btn-primary" - groups="base.group_system" + groups="odoo_repository.group_odoo_repository_manager" /> +
- - + + @@ -66,7 +77,7 @@ From 2b8a7529964c708642c5b1b033f81393298e9c1a Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 10 Dec 2025 10:45:29 +0000 Subject: [PATCH 124/160] [BOT] post-merge updates --- odoo_repository/README.rst | 18 +++++++++--------- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 67508b4e..15001e49 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:cdf0bfc94bfff314ee67156f2c2ed65f9a608ff2655724e3acdb17a91bbd172f + !! source digest: sha256:9e8dd0fb07589861c8448c24706bdd9df8e737b069f14289ce0778723958346c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -20,14 +20,14 @@ Odoo Repositories Data .. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fodoo--repository-lightgray.png?logo=github - :target: https://github.com/OCA/odoo-repository/tree/16.0/odoo_repository - :alt: OCA/odoo-repository +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmodule--composition--analysis-lightgray.png?logo=github + :target: https://github.com/OCA/module-composition-analysis/tree/16.0/odoo_repository + :alt: OCA/module-composition-analysis .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/odoo-repository-16-0/odoo-repository-16-0-odoo_repository + :target: https://translation.odoo-community.org/projects/module-composition-analysis-16-0/module-composition-analysis-16-0-odoo_repository :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/odoo-repository&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/module-composition-analysis&target_branch=16.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -50,10 +50,10 @@ It allows you to: Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -85,6 +85,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/odoo-repository `_ project on GitHub. +This module is part of the `OCA/module-composition-analysis `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 5fcae1ae..da2e0d11 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.3.2", + "version": "16.0.1.4.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/camptocamp/odoo-repository", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index bf9c4fda..e374e415 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,9 +372,9 @@

Odoo Repositories Data

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:cdf0bfc94bfff314ee67156f2c2ed65f9a608ff2655724e3acdb17a91bbd172f +!! source digest: sha256:9e8dd0fb07589861c8448c24706bdd9df8e737b069f14289ce0778723958346c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/odoo-repository Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

Base module to host data collected from Odoo repositories.

It allows you to:

    @@ -398,10 +398,10 @@

    Odoo Repositories Data

    Bug Tracker

    -

    Bugs are tracked on GitHub Issues. +

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

    +feedback.

    Do not contact contributors directly about support or help with technical issues.

    @@ -430,7 +430,7 @@

    Maintainers

    OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

    -

    This module is part of the OCA/odoo-repository project on GitHub.

    +

    This module is part of the OCA/module-composition-analysis project on GitHub.

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    From 5dbc1f65334370785eb41073a73c73e113c445aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 10 Dec 2025 13:52:48 +0100 Subject: [PATCH 125/160] [FIX] odoo_repository: prevent duplicated module versions --- odoo_repository/__manifest__.py | 2 +- .../migrations/16.0.1.4.1/pre-migration.py | 36 +++++++++++++++++++ .../models/odoo_module_branch_version.py | 8 +++++ .../tests/test_odoo_repository_scan.py | 2 ++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 odoo_repository/migrations/16.0.1.4.1/pre-migration.py diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index da2e0d11..81c84c4c 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.4.0", + "version": "16.0.1.4.1", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/camptocamp/odoo-repository", diff --git a/odoo_repository/migrations/16.0.1.4.1/pre-migration.py b/odoo_repository/migrations/16.0.1.4.1/pre-migration.py new file mode 100644 index 00000000..1c257165 --- /dev/null +++ b/odoo_repository/migrations/16.0.1.4.1/pre-migration.py @@ -0,0 +1,36 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + delete_duplicated_module_versions(cr) + + +def delete_duplicated_module_versions(cr): + _logger.info("Delete duplicated module versions...") + query = """ + DELETE FROM odoo_module_branch_version mbv + WHERE EXISTS( + SELECT 1 + FROM ( + SELECT + id, + module_branch_id, + name, + manifest_value, + ROW_NUMBER() OVER ( + PARTITION BY module_branch_id, name, manifest_value + ORDER BY create_date + ) rn + FROM odoo_module_branch_version + ) t + where t.rn > 1 and t.id = mbv.id + ) + RETURNING mbv.id; + """ + cr.execute(query) + rows = cr.fetchall() + _logger.info("%s duplicated module versions deleted.", len(rows)) diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index e248a24e..ce14f9a4 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -49,6 +49,14 @@ class OdooModuleBranchVersion(models.Model): ) sequence = fields.Integer() + _sql_constraints = [ + ( + "module_branch_id_name_manifest_value_uniq", + "UNIQUE (module_branch_id, name, manifest_value)", + "This version already exists for this module.", + ), + ] + @api.model_create_multi def create(self, vals_list): res = super().create(vals_list) diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 558963ba..9298124f 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -116,6 +116,7 @@ def test_action_scan_orphaned_module_exists(self): "repository_branch_id": False, "last_scanned_commit": False, "dependency_ids": False, + "version_ids": False, } ) # Launch a scan @@ -159,6 +160,7 @@ def _create_unmerged_module_branch(self): "repository_branch_id": wrong_repo_branch, "last_scanned_commit": False, "dependency_ids": False, + "version_ids": False, "pr_url": f"{wrong_repo.repo_url}/pull/1", "specific": False, } From bd230ed0d44bafaaf75f929a7707696e4bfe2878 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 10 Dec 2025 14:27:43 +0000 Subject: [PATCH 126/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 15001e49..c1ff9896 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:9e8dd0fb07589861c8448c24706bdd9df8e737b069f14289ce0778723958346c + !! source digest: sha256:18d50f61e1171643f92e31b532a304d66f5a9fb50371af51d2cc0371b76859c5 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 81c84c4c..cb9c32ed 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.4.1", + "version": "16.0.1.4.2", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/camptocamp/odoo-repository", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index e374e415..3084f378 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:9e8dd0fb07589861c8448c24706bdd9df8e737b069f14289ce0778723958346c +!! source digest: sha256:18d50f61e1171643f92e31b532a304d66f5a9fb50371af51d2cc0371b76859c5 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From 7f28133d7764a5da495befc0edbc6df29050726a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 9 Jan 2026 16:59:05 +0100 Subject: [PATCH 127/160] [FIX] odoo_repository: followup, add 'base' dependency by default on scanned modules (if not set) --- odoo_repository/models/odoo_module_branch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 44f621c7..57f1c521 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -424,7 +424,7 @@ def _prepare_module_branch_values(self, repo_branch, module, data): dependency_ids = self._get_dependency_ids( repo_branch, # Set at least a dependency on "base" if not defined - manifest.get("depends", ["base"]), + manifest.get("depends") or ["base"], ) external_dependencies = manifest.get("external_dependencies", {}) python_dependency_ids = self._get_python_dependency_ids( From 66d62979afdbb33faf41047eed9afa3655c12fb4 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 11 Jan 2026 12:01:04 +0000 Subject: [PATCH 128/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index c1ff9896..ceca9215 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:18d50f61e1171643f92e31b532a304d66f5a9fb50371af51d2cc0371b76859c5 + !! source digest: sha256:5ada6f14b8417e8809cb9bf0e4c6a21975164697100155162d0a4fe52ea95676 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index cb9c32ed..8c3b25f3 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.4.2", + "version": "16.0.1.4.3", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/camptocamp/odoo-repository", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 3084f378..55709e5a 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:18d50f61e1171643f92e31b532a304d66f5a9fb50371af51d2cc0371b76859c5 +!! source digest: sha256:5ada6f14b8417e8809cb9bf0e4c6a21975164697100155162d0a4fe52ea95676 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From c3b6ec1cf885d8fb175bd4b8474f103eee85bac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sat, 17 Jan 2026 13:16:12 +0100 Subject: [PATCH 129/160] Update all modules manifest and README with new repo info --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index ceca9215..f0901b75 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:5ada6f14b8417e8809cb9bf0e4c6a21975164697100155162d0a4fe52ea95676 + !! source digest: sha256:37b421dbeae4f4b8b515291aa4339cbe66904f86d56201483542c507b45696b9 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 8c3b25f3..6643c6ce 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -6,7 +6,7 @@ "version": "16.0.1.4.3", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", - "website": "https://github.com/camptocamp/odoo-repository", + "website": "https://github.com/OCA/module-composition-analysis", "data": [ "security/res_groups.xml", "security/ir.model.access.csv", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 55709e5a..e2892f1b 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:5ada6f14b8417e8809cb9bf0e4c6a21975164697100155162d0a4fe52ea95676 +!! source digest: sha256:37b421dbeae4f4b8b515291aa4339cbe66904f86d56201483542c507b45696b9 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From df6090b75fa85b2a40d15a7c308f8d32cadb124a Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 17 Jan 2026 12:45:07 +0000 Subject: [PATCH 130/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index f0901b75..06952392 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:37b421dbeae4f4b8b515291aa4339cbe66904f86d56201483542c507b45696b9 + !! source digest: sha256:f4c98a6da3a2dd638fb292cbbae95681079c337555ced17cd560dd565e89dd17 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 6643c6ce..18bbdcec 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.4.3", + "version": "16.0.1.4.4", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index e2892f1b..9daa8239 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:37b421dbeae4f4b8b515291aa4339cbe66904f86d56201483542c507b45696b9 +!! source digest: sha256:f4c98a6da3a2dd638fb292cbbae95681079c337555ced17cd560dd565e89dd17 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From 4f16f7a18b4ea19fa72c1e8faf12685c0d7dbbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 1 Feb 2026 11:56:11 +0100 Subject: [PATCH 131/160] [IMP] odoo_repository: pre-commit auto fixes --- odoo_repository/data/ir_cron.xml | 50 +- odoo_repository/data/odoo_branch.xml | 106 ++-- odoo_repository/data/odoo_module.xml | 18 +- odoo_repository/data/odoo_repository.xml | 64 ++- .../data/odoo_repository_addons_path.xml | 58 +- odoo_repository/data/odoo_repository_org.xml | 14 +- odoo_repository/data/queue_job.xml | 66 ++- odoo_repository/models/odoo_module_branch.py | 6 +- odoo_repository/models/odoo_repository.py | 4 +- odoo_repository/pyproject.toml | 3 + odoo_repository/security/res_groups.xml | 28 +- .../views/authentication_token.xml | 86 ++- odoo_repository/views/menu.xml | 12 +- odoo_repository/views/odoo_author.xml | 80 ++- odoo_repository/views/odoo_branch.xml | 112 ++-- odoo_repository/views/odoo_license.xml | 54 +- odoo_repository/views/odoo_maintainer.xml | 110 ++-- odoo_repository/views/odoo_module.xml | 126 +++-- odoo_repository/views/odoo_module_branch.xml | 509 ++++++++++-------- .../views/odoo_module_category.xml | 54 +- .../views/odoo_module_dev_status.xml | 54 +- .../views/odoo_python_dependency.xml | 54 +- odoo_repository/views/odoo_repository.xml | 221 ++++---- .../views/odoo_repository_addons_path.xml | 72 ++- .../views/odoo_repository_branch.xml | 97 ++-- odoo_repository/views/odoo_repository_org.xml | 80 ++- odoo_repository/views/res_config_settings.xml | 149 ++--- odoo_repository/views/ssh_key.xml | 86 ++- 28 files changed, 1204 insertions(+), 1169 deletions(-) create mode 100644 odoo_repository/pyproject.toml diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 6c5e8ca0..45d5b47c 100644 --- a/odoo_repository/data/ir_cron.xml +++ b/odoo_repository/data/ir_cron.xml @@ -2,37 +2,35 @@ - - - Odoo Repositories - Scanner - 1 - days - -1 - - - + Odoo Repositories - Scanner + 1 + days + -1 + + + - - code - model.cron_scanner() - + + code + model.cron_scanner() + - - Odoo Repositories - Fetch data from main node - 1 - days - -1 - - - + Odoo Repositories - Fetch data from main node + 1 + days + -1 + + + - - code - model.cron_fetch_data() - - + + code + model.cron_fetch_data() + diff --git a/odoo_repository/data/odoo_branch.xml b/odoo_repository/data/odoo_branch.xml index 46196f60..23b6fab5 100644 --- a/odoo_repository/data/odoo_branch.xml +++ b/odoo_repository/data/odoo_branch.xml @@ -2,8 +2,7 @@ - - - - 8.0 - - + + 8.0 + + - - 9.0 - - + + 9.0 + + - - 10.0 - - + + 10.0 + + - - 11.0 - - + + 11.0 + + - - 12.0 - - + + 12.0 + + - - 13.0 - - + + 13.0 + + - - 14.0 - - + + 14.0 + + - - 15.0 - - + + 15.0 + + - - 16.0 - - + + 16.0 + + - - 17.0 - - + + 17.0 + + - - 18.0 - - + + 18.0 + + - - + + - - - + + - - - + + diff --git a/odoo_repository/data/odoo_module.xml b/odoo_repository/data/odoo_module.xml index fdca6d0e..bf8ca9cb 100644 --- a/odoo_repository/data/odoo_module.xml +++ b/odoo_repository/data/odoo_module.xml @@ -2,15 +2,13 @@ + + server_environment_files + + - - server_environment_files - - - - - studio_customization - - - + + studio_customization + + diff --git a/odoo_repository/data/odoo_repository.xml b/odoo_repository/data/odoo_repository.xml index b65a0c5c..d6cdfaa3 100644 --- a/odoo_repository/data/odoo_repository.xml +++ b/odoo_repository/data/odoo_repository.xml @@ -2,15 +2,14 @@ - - - - odoo - - https://github.com/odoo/odoo - https://github.com/odoo/odoo - github - + + odoo + + https://github.com/odoo/odoo + https://github.com/odoo/odoo + github + - - + - - - enterprise - - - https://github.com/odoo/enterprise - https://github.com/odoo/enterprise - github - + + enterprise + + + https://github.com/odoo/enterprise + https://github.com/odoo/enterprise + github + - - + - - - design-themes - - https://github.com/odoo/design-themes - https://github.com/odoo/design-themes - github - + + design-themes + + https://github.com/odoo/design-themes + https://github.com/odoo/design-themes + github + - - - + diff --git a/odoo_repository/data/odoo_repository_addons_path.xml b/odoo_repository/data/odoo_repository_addons_path.xml index 1971cca8..dffbecc1 100644 --- a/odoo_repository/data/odoo_repository_addons_path.xml +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -2,58 +2,56 @@ - - - ./odoo/addons - - + ./odoo/addons + + - - ./addons - - + ./addons + + - - . - - + . + + - - . - - - + . + + + - - . - - + . + + - - . - - - - - . - + . + + + + . + diff --git a/odoo_repository/data/odoo_repository_org.xml b/odoo_repository/data/odoo_repository_org.xml index bd8ce0da..be448708 100644 --- a/odoo_repository/data/odoo_repository_org.xml +++ b/odoo_repository/data/odoo_repository_org.xml @@ -2,13 +2,11 @@ + + odoo + - - odoo - - - - OCA - - + + OCA + diff --git a/odoo_repository/data/queue_job.xml b/odoo_repository/data/queue_job.xml index 21292108..f38e67a9 100644 --- a/odoo_repository/data/queue_job.xml +++ b/odoo_repository/data/queue_job.xml @@ -2,55 +2,53 @@ + + odoo_repository_scan + + - - odoo_repository_scan - - - - - - _detect_modules_to_scan_on_branch - - - + + _detect_modules_to_scan_on_branch + + + - - - _scan_module_on_branch - - - + + _scan_module_on_branch + + + - - - _update_last_scanned_commit - - - + + _update_last_scanned_commit + + + - - odoo_repository_find_pr_url - - + + odoo_repository_find_pr_url + + - - - action_find_pr_url - - - - + + action_find_pr_url + + + diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 57f1c521..315b01cf 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -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 @@ -291,9 +291,7 @@ def _compute_dependency_level(self): rec.non_std_dependency_level = ( # Set 0 on all std modules so they will always have a dependency # level inferior to non-std modules - (non_std_max_parent_level + 1) - if not rec.is_standard - else 0 + (non_std_max_parent_level + 1) if not rec.is_standard else 0 ) def _get_recursive_dependencies(self, domain=None): diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index c9e4a01d..f0d0ec38 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -207,8 +207,8 @@ def _check_config(self): if not repositories_path: raise UserError( _( - "Please define the '{key}' system parameter to " - "clone repositories in the folder of your choice.".format(key=key) + f"Please define the '{key}' system parameter to " + "clone repositories in the folder of your choice." ) ) # Ensure the folder exists diff --git a/odoo_repository/pyproject.toml b/odoo_repository/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/odoo_repository/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/odoo_repository/security/res_groups.xml b/odoo_repository/security/res_groups.xml index 191d932f..1d664ab6 100644 --- a/odoo_repository/security/res_groups.xml +++ b/odoo_repository/security/res_groups.xml @@ -2,24 +2,22 @@ + + Odoo Repository + 50 + - - Odoo Repository - 50 - + + Odoo Repository User + + - - Odoo Repository User - - - - - Odoo Repository Manager - - + Odoo Repository Manager + + - - + diff --git a/odoo_repository/views/authentication_token.xml b/odoo_repository/views/authentication_token.xml index 8f30b01c..56e84dff 100644 --- a/odoo_repository/views/authentication_token.xml +++ b/odoo_repository/views/authentication_token.xml @@ -2,62 +2,60 @@ - - - authentication.token.form - authentication.token - + authentication.token.form + authentication.token + - - - - - - - - - - - + +
    + + + + + + +
    +
    + - - authentication.token.tree - authentication.token - - - - - - + + authentication.token.tree + authentication.token + + + + + + - - authentication.token.search - authentication.token - search - - - - - - + + authentication.token.search + authentication.token + search + + + + + + - - Authentication Tokens - ir.actions.act_window - authentication.token - - + Authentication Tokens + ir.actions.act_window + authentication.token + + - + - -
    diff --git a/odoo_repository/views/menu.xml b/odoo_repository/views/menu.xml index a3afad34..af727c82 100644 --- a/odoo_repository/views/menu.xml +++ b/odoo_repository/views/menu.xml @@ -2,37 +2,35 @@ - - - - - - - diff --git a/odoo_repository/views/odoo_author.xml b/odoo_repository/views/odoo_author.xml index 08363c24..7c6b6b7d 100644 --- a/odoo_repository/views/odoo_author.xml +++ b/odoo_repository/views/odoo_author.xml @@ -2,53 +2,51 @@ + + odoo.author.form + odoo.author + +
    + + + + + +
    +
    +
    - - odoo.author.form - odoo.author - -
    - - - - - -
    -
    -
    + + odoo.author.tree + odoo.author + + + + + + - - odoo.author.tree - odoo.author - - - - - - + + odoo.author.search + odoo.author + search + + + + + + - - odoo.author.search - odoo.author - search - - - - - - + + Authors + ir.actions.act_window + odoo.author + + - - Authors - ir.actions.act_window - odoo.author - - - - -
    diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index 806fdbfd..62bbfdb3 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -2,21 +2,20 @@ - - - odoo.branch.form - odoo.branch - -
    -
    -
    - - - + + + - - - - - - - - - - - - - -
    + + + + + + + + + + + + + + - - odoo.branch.tree - odoo.branch - - - - - - + + odoo.branch.tree + odoo.branch + + + + + + - - odoo.branch.search - odoo.branch - search - - - - - - - + + odoo.branch.search + odoo.branch + search + + + + + + + - - Versions - ir.actions.act_window - odoo.branch - - + + Versions + ir.actions.act_window + odoo.branch + + - -
    diff --git a/odoo_repository/views/odoo_license.xml b/odoo_repository/views/odoo_license.xml index 9eed126b..2d7e781f 100644 --- a/odoo_repository/views/odoo_license.xml +++ b/odoo_repository/views/odoo_license.xml @@ -2,39 +2,37 @@ + + odoo.license.tree + odoo.license + + + + + + - - odoo.license.tree - odoo.license - - - - - - + + odoo.license.search + odoo.license + search + + + + + + - - odoo.license.search - odoo.license - search - - - - - - + + Licenses + ir.actions.act_window + odoo.license + + - - Licenses - ir.actions.act_window - odoo.license - - - - - diff --git a/odoo_repository/views/odoo_maintainer.xml b/odoo_repository/views/odoo_maintainer.xml index f15d58ee..8fe71e2d 100644 --- a/odoo_repository/views/odoo_maintainer.xml +++ b/odoo_repository/views/odoo_maintainer.xml @@ -2,68 +2,66 @@ + + odoo.maintainer.form + odoo.maintainer + +
    + + + + + + + + + + + + + + + + + + + +
    +
    +
    - - odoo.maintainer.form - odoo.maintainer - -
    - - - - - - - - - - - - - - - - - - - -
    -
    -
    + + odoo.maintainer.tree + odoo.maintainer + + + + + + + - - odoo.maintainer.tree - odoo.maintainer - - - - - - - + + odoo.maintainer.search + odoo.maintainer + search + + + + + + - - odoo.maintainer.search - odoo.maintainer - search - - - - - - + + Maintainers + ir.actions.act_window + odoo.maintainer + + - - Maintainers - ir.actions.act_window - odoo.maintainer - - - - -
    diff --git a/odoo_repository/views/odoo_module.xml b/odoo_repository/views/odoo_module.xml index b7a2efd9..b375f8a8 100644 --- a/odoo_repository/views/odoo_module.xml +++ b/odoo_repository/views/odoo_module.xml @@ -2,89 +2,87 @@ - - - odoo.module.form - odoo.module - -
    - -
    + odoo.module.form + odoo.module + + + + - - - - - +
    + + + + + - - - - - - - - - - - - - -
    -
    -
    -
    + + + + + + + + + + + + + + + + + - - odoo.module.tree - odoo.module - - - - - - - + + odoo.module.tree + odoo.module + + + + + + + - - odoo.module.search - odoo.module - search - - - - + odoo.module.search + odoo.module + search + + + + - - - + + + - - Technical Module Names - ir.actions.act_window - odoo.module - - {"default_blacklisted": 1} - + + Technical Module Names + ir.actions.act_window + odoo.module + + {"default_blacklisted": 1} + - -
    diff --git a/odoo_repository/views/odoo_module_branch.xml b/odoo_repository/views/odoo_module_branch.xml index afe40533..7f53949a 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -2,315 +2,360 @@ - - - odoo.module.branch.form - odoo.module.branch - -
    - -
    - -
    - - - - - - - - - - Dependencies + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - odoo.module.branch.tree - odoo.module.branch - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + odoo.module.branch.tree + odoo.module.branch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - odoo.module.branch.tree.recursive_dependencies - odoo.module.branch - - primary - 100 - - - + odoo.module.branch.tree.recursive_dependencies + odoo.module.branch + + primary + 100 + + + global_dependency_level, non_std_dependency_level, module_name - - - show - - - show - - - show - - - + + + show + + + show + + + show + + + - - odoo.module.branch.search - odoo.module.branch - search - - - - - - - - - - - - - + odoo.module.branch.search + odoo.module.branch + search + + + + + + + + + + + + + - - - - + - - - - + - - - - + - - - - - - - - - + + + + - - odoo.module.branch.pivot - odoo.module.branch - - - - - - - + + odoo.module.branch.pivot + odoo.module.branch + + + + + + + - - odoo.module.branch.graph - odoo.module.branch - - - - - - + + odoo.module.branch.graph + odoo.module.branch + + + + + + - - Modules - ir.actions.act_window - odoo.module.branch - tree,form - - { + + Modules + ir.actions.act_window + odoo.module.branch + tree,form + + { 'search_default_installable': 1, 'search_default_with_repository': 2 } - + - - Modules Analysis - ir.actions.act_window - odoo.module.branch - pivot,graph - - { + + Modules Analysis + ir.actions.act_window + odoo.module.branch + pivot,graph + + { 'search_default_installable': 1, 'search_default_with_repository': 2, 'pivot_column_groupby': ['branch_id'], @@ -320,34 +365,36 @@ 'graph_measure': '__count__', 'graph_groupbys': ['branch_id', 'org_id'], } - + - - Dependencies - ir.actions.act_window - odoo.module.branch - - + Dependencies + ir.actions.act_window + odoo.module.branch + + - - - -
    diff --git a/odoo_repository/views/odoo_module_category.xml b/odoo_repository/views/odoo_module_category.xml index 63eedb6d..36bf39d6 100644 --- a/odoo_repository/views/odoo_module_category.xml +++ b/odoo_repository/views/odoo_module_category.xml @@ -2,39 +2,37 @@ + + odoo.module.category.tree + odoo.module.category + + + + + + - - odoo.module.category.tree - odoo.module.category - - - - - - + + odoo.module.category.search + odoo.module.category + search + + + + + + - - odoo.module.category.search - odoo.module.category - search - - - - - - + + Categories + ir.actions.act_window + odoo.module.category + + - - Categories - ir.actions.act_window - odoo.module.category - - - - - diff --git a/odoo_repository/views/odoo_module_dev_status.xml b/odoo_repository/views/odoo_module_dev_status.xml index 9cc0dc00..65e5daba 100644 --- a/odoo_repository/views/odoo_module_dev_status.xml +++ b/odoo_repository/views/odoo_module_dev_status.xml @@ -2,39 +2,37 @@ + + odoo.module.dev.status.tree + odoo.module.dev.status + + + + + + - - odoo.module.dev.status.tree - odoo.module.dev.status - - - - - - + + odoo.module.dev.status.search + odoo.module.dev.status + search + + + + + + - - odoo.module.dev.status.search - odoo.module.dev.status - search - - - - - - + + Development Status + ir.actions.act_window + odoo.module.dev.status + + - - Development Status - ir.actions.act_window - odoo.module.dev.status - - - - - diff --git a/odoo_repository/views/odoo_python_dependency.xml b/odoo_repository/views/odoo_python_dependency.xml index 95d8299c..085a118a 100644 --- a/odoo_repository/views/odoo_python_dependency.xml +++ b/odoo_repository/views/odoo_python_dependency.xml @@ -2,39 +2,37 @@ + + odoo.python.dependency.tree + odoo.python.dependency + + + + + + - - odoo.python.dependency.tree - odoo.python.dependency - - - - - - + + odoo.python.dependency.search + odoo.python.dependency + search + + + + + + - - odoo.python.dependency.search - odoo.python.dependency - search - - - - - - + + Python Dependencies + ir.actions.act_window + odoo.python.dependency + + - - Python Dependencies - ir.actions.act_window - odoo.python.dependency - - - - - diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index 46b7b3d1..8daf65e6 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -2,14 +2,13 @@ - - - odoo.repository.form - odoo.repository - -
    -
    -
    - -
    - - -
    - - - - - - - - - - - - Modules + + + + + + + + + + + + + + - - - - - + + - - + - - - - - - + + + + + - - - - - - - - - - - + + + + + + + + + + - - + - - - - - - - -
    -
    -
    -
    + + + + + + + + + + - - odoo.repository.tree - odoo.repository - - - - - - - - - - + + odoo.repository.tree + odoo.repository + + + + + + + + + + - - odoo.repository.search - odoo.repository - search - - - - - - - - + odoo.repository.search + odoo.repository + search + + + + + + + + - - - - + + + + - - Repositories - ir.actions.act_window - odoo.repository - - + + Repositories + ir.actions.act_window + odoo.repository + + - -
    diff --git a/odoo_repository/views/odoo_repository_addons_path.xml b/odoo_repository/views/odoo_repository_addons_path.xml index b49cd638..80434725 100644 --- a/odoo_repository/views/odoo_repository_addons_path.xml +++ b/odoo_repository/views/odoo_repository_addons_path.xml @@ -2,48 +2,46 @@ + + odoo.repository.addons_path.form + odoo.repository.addons_path + +
    + + + + + + + + +
    +
    +
    - - odoo.repository.addons_path.form - odoo.repository.addons_path - -
    - - - - - - - - -
    -
    -
    + + odoo.repository.addons_path.tree + odoo.repository.addons_path + + + + + + + + + - - odoo.repository.addons_path.tree - odoo.repository.addons_path - - - - - - - - - + + Addons Path + ir.actions.act_window + odoo.repository.addons_path + + - - Addons Path - ir.actions.act_window - odoo.repository.addons_path - - - - -
    diff --git a/odoo_repository/views/odoo_repository_branch.xml b/odoo_repository/views/odoo_repository_branch.xml index 25640b82..19b0c79e 100644 --- a/odoo_repository/views/odoo_repository_branch.xml +++ b/odoo_repository/views/odoo_repository_branch.xml @@ -2,59 +2,62 @@ - - - odoo.repository.branch.form - odoo.repository.branch - -
    -
    -
    - - - - - - - - - - - -
    -
    -
    - - - odoo.repository.branch.tree - odoo.repository.branch - - - - - - - - + + + + + + + + + + + + + + + - - odoo.repository.branch.search - odoo.repository.branch - search - - - - - - - + + odoo.repository.branch.tree + odoo.repository.branch + + + + + + + + + + odoo.repository.branch.search + odoo.repository.branch + search + + + + + + +
    diff --git a/odoo_repository/views/odoo_repository_org.xml b/odoo_repository/views/odoo_repository_org.xml index 0a46814c..5e04bd32 100644 --- a/odoo_repository/views/odoo_repository_org.xml +++ b/odoo_repository/views/odoo_repository_org.xml @@ -2,53 +2,51 @@ + + odoo.repository.org.form + odoo.repository.org + +
    + + + + + +
    +
    +
    - - odoo.repository.org.form - odoo.repository.org - -
    - - - - - -
    -
    -
    + + odoo.repository.org.tree + odoo.repository.org + + + + + + - - odoo.repository.org.tree - odoo.repository.org - - - - - - + + odoo.repository.org.search + odoo.repository.org + search + + + + + + - - odoo.repository.org.search - odoo.repository.org - search - - - - - - + + Repository Organizations + ir.actions.act_window + odoo.repository.org + + - - Repository Organizations - ir.actions.act_window - odoo.repository.org - - - - -
    diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 2bd509b3..d12901b4 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -2,116 +2,124 @@ - - - res.config.settings.form.inherit - res.config.settings - - - -
    + res.config.settings.form.inherit + res.config.settings + + + +
    -
    -

    Odoo Repositories

    -
    +

    Odoo Repositories

    +
    -
    - Storage local path -
    +
    + Storage local path +
    Where to clone repositories on the local filesystem.
    -
    -
    - +
    + -
    -
    -
    -
    -
    +
    +
    +
    +
    -
    -
    - -
    -
    - Workaround FS errors -
    +
    +
    + +
    +
    + Workaround FS errors +
    Fix file system permissions when cloning repositories.
    -
    -
    -
    -
    +
    +
    +
    -
    - Default Authentication Token -
    +
    + Default Authentication Token +
    Default token used to clone repositories and authenticate on API like GitHub.
    -
    -
    - +
    + -
    -
    -
    +
    +
    +
    If the environment variable GITHUB_TOKEN is set on the running system, it will be used as fallback. + >GITHUB_TOKEN is set on the running system, it will be used as fallback.
    -
    -
    -
    -
    - Main Node -
    +
    +
    +
    +
    + Main Node +
    Endpoint URL of the main node from which modules data can be fetched.
    -
    -
    - +
    + +
    +
    +
    +
    -
    -
    -
    -
    -
    - - - +
    + + + - - Settings - ir.actions.act_window - res.config.settings - - form - inline - {'module' : 'odoo_repository', 'bin_size': False} - + + Settings + ir.actions.act_window + res.config.settings + + form + inline + {'module' : 'odoo_repository', 'bin_size': False} + - - diff --git a/odoo_repository/views/ssh_key.xml b/odoo_repository/views/ssh_key.xml index ab6dd23a..5da1c2bb 100644 --- a/odoo_repository/views/ssh_key.xml +++ b/odoo_repository/views/ssh_key.xml @@ -2,62 +2,60 @@ - - - ssh.key.form - ssh.key - + ssh.key.form + ssh.key + - -
    - - - - - - -
    -
    -
    + +
    + + + + + + +
    +
    + - - ssh.key.tree - ssh.key - - - - - - + + ssh.key.tree + ssh.key + + + + + + - - ssh.key.search - ssh.key - search - - - - - - + + ssh.key.search + ssh.key + search + + + + + + - - SSH Keys - ir.actions.act_window - ssh.key - - + SSH Keys + ir.actions.act_window + ssh.key + + - + - -
    From 010a41acb21066677d60b93a85517f4cb0a64c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 1 Feb 2026 12:18:53 +0100 Subject: [PATCH 132/160] [IMP] odoo_repository: pre-commit fixes --- odoo_repository/lib/scanner.py | 6 +++--- odoo_repository/models/odoo_module_branch.py | 6 ++++-- odoo_repository/models/res_company.py | 5 ++++- odoo_repository/utils/module.py | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 6871b019..d06dfd67 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -565,9 +565,9 @@ def _scan_migration_path( ) if not module_branch_id: _logger.warning( - "Module '%s' for version %s does not exist on Odoo, " - "a new scan of the repository is required. Aborted" - % (module, source_version) + "Module '%(module)s' for version %(version)s does not exist " + "on Odoo, a new scan of the repository is required. Aborted", + {"module": module, "version": source_version}, ) continue # For each module and source/target branch: diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 315b01cf..ba39bffa 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -613,7 +613,9 @@ def _prepare_module_branch_version_ids_values( ) continue module_version = module_branch.version_ids.filtered( - lambda v: v.name == name and v.manifest_value == manifest_value + lambda v, name=name, manifest_value=manifest_value: ( + v.name == name and v.manifest_value == manifest_value + ) ) values = { "name": name, @@ -724,7 +726,7 @@ def _find(self, branch, module, repo, domain=None): @api.model def _find_or_create(self, branch, module, repo, domain=None): - """Find an `odoo.module.branch` record (see `_find`), or create an orphaned one.""" + """Find an `odoo.module.branch` record, or create an orphaned one.""" module_branch = self._find(branch, module, repo, domain=domain) # If still not found, create the module as an orphaned module # (it will hopefully be bound to a repository later) diff --git a/odoo_repository/models/res_company.py b/odoo_repository/models/res_company.py index c208251a..17a80c80 100644 --- a/odoo_repository/models/res_company.py +++ b/odoo_repository/models/res_company.py @@ -10,7 +10,10 @@ class ResCompany(models.Model): config_odoo_repository_default_token_id = fields.Many2one( comodel_name="authentication.token", string="Default token", - help="Default token used to clone repositories and authenticate on API like GitHub.", + help=( + "Default token used to clone repositories and authenticate " + "on API like GitHub." + ), ) config_odoo_repository_workaround_fs_errors = fields.Boolean( string="Workaround FS errors", diff --git a/odoo_repository/utils/module.py b/odoo_repository/utils/module.py index e622e074..a20c827c 100644 --- a/odoo_repository/utils/module.py +++ b/odoo_repository/utils/module.py @@ -21,5 +21,5 @@ def adapt_version(major_version: str, module_version: str): if module_version == major_version or not module_version.startswith( major_version + "." ): - module_version = "%s.%s" % (major_version, module_version) + module_version = f"{major_version}.{module_version}" return module_version From 64e114d0200b4d766de325e38d973b88c2f073a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 1 Feb 2026 11:59:01 +0100 Subject: [PATCH 133/160] [MIG] odoo_repository: Migration to 18.0 --- odoo_repository/__manifest__.py | 2 +- odoo_repository/data/ir_cron.xml | 8 +- .../migrations/16.0.1.1.0/post-migration.py | 26 ---- .../migrations/16.0.1.2.0/pre-migration.py | 43 ------ .../migrations/16.0.1.3.0/post-migration.py | 19 --- .../migrations/16.0.1.3.1/post-migration.py | 19 --- .../migrations/16.0.1.4.1/pre-migration.py | 36 ----- .../models/odoo_module_branch_version.py | 2 +- odoo_repository/models/res_company.py | 2 +- odoo_repository/models/res_config_settings.py | 2 +- odoo_repository/security/res_groups.xml | 6 +- .../views/authentication_token.xml | 4 +- odoo_repository/views/menu.xml | 2 +- odoo_repository/views/odoo_author.xml | 4 +- odoo_repository/views/odoo_branch.xml | 13 +- odoo_repository/views/odoo_license.xml | 4 +- odoo_repository/views/odoo_maintainer.xml | 8 +- odoo_repository/views/odoo_module.xml | 12 +- odoo_repository/views/odoo_module_branch.xml | 30 ++-- .../views/odoo_module_category.xml | 4 +- .../views/odoo_module_dev_status.xml | 4 +- .../views/odoo_python_dependency.xml | 4 +- odoo_repository/views/odoo_repository.xml | 61 +++----- .../views/odoo_repository_addons_path.xml | 4 +- .../views/odoo_repository_branch.xml | 4 +- odoo_repository/views/odoo_repository_org.xml | 4 +- odoo_repository/views/res_config_settings.xml | 133 ++++++------------ odoo_repository/views/ssh_key.xml | 4 +- 28 files changed, 120 insertions(+), 344 deletions(-) delete mode 100644 odoo_repository/migrations/16.0.1.1.0/post-migration.py delete mode 100644 odoo_repository/migrations/16.0.1.2.0/pre-migration.py delete mode 100644 odoo_repository/migrations/16.0.1.3.0/post-migration.py delete mode 100644 odoo_repository/migrations/16.0.1.3.1/post-migration.py delete mode 100644 odoo_repository/migrations/16.0.1.4.1/pre-migration.py diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 18bbdcec..8cbc14c8 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "16.0.1.4.4", + "version": "18.0.1.0.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 45d5b47c..9a55c900 100644 --- a/odoo_repository/data/ir_cron.xml +++ b/odoo_repository/data/ir_cron.xml @@ -3,12 +3,10 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - Odoo Repositories - Scanner + Odoo MCA - Scanner 1 days - -1 - - Odoo Repositories - Fetch data from main node + Odoo MCA - Fetch data from main node 1 days - -1 - .specific' field...") - repos = env["odoo.repository"].with_context(active_test=False).search([]) - for repo in repos: - modules = repo.branch_ids.module_ids - modules.write({"specific": repo.specific}) - # Dependencies of generic modules should be aligned (to update orphaned - # dependencies if any, don't care about filtering here, updating - # thousands of records at once is OK) - if not repo.specific: - orphaned_deps = modules._get_recursive_dependencies() - orphaned_deps.write({"specific": False}) diff --git a/odoo_repository/migrations/16.0.1.2.0/pre-migration.py b/odoo_repository/migrations/16.0.1.2.0/pre-migration.py deleted file mode 100644 index 158310ab..00000000 --- a/odoo_repository/migrations/16.0.1.2.0/pre-migration.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging - -from openupgradelib import openupgrade as ou - -from odoo.tools import sql - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not version: - return - set_xml_ids_on_odoo_branch(cr) - migrate_repository_clone_branch_id_to_repository_branch(cr) - - -def set_xml_ids_on_odoo_branch(cr): - _logger.info("Add XML-ID on 'odoo.branch' 18.0...") - query = "SELECT id FROM odoo_branch WHERE name='18.0';" - cr.execute(query) - row = cr.fetchone() - if row: - branch_id = row[0] - ou.add_xmlid(cr, "odoo_repository", "odoo_branch_18", "odoo.branch", branch_id) - - -def migrate_repository_clone_branch_id_to_repository_branch(cr): - _logger.info( - "Migrate 'clone_branch_id' from 'odoo.repository' to 'odoo.repository.branch'..." - ) - # Create 'odoo_repository_branch.cloned_branch' - if not sql.column_exists(cr, "odoo_repository_branch", "cloned_branch"): - sql.create_column(cr, "odoo_repository_branch", "cloned_branch", "varchar") - # Migrate values from 'odoo_repository.clone_branch_id' to this new column - query = """ - UPDATE odoo_repository_branch - SET cloned_branch=br.name - FROM odoo_repository repo - JOIN odoo_branch br - ON repo.clone_branch_id=br.id - WHERE repo.id = odoo_repository_branch.repository_id; - """ - cr.execute(query) diff --git a/odoo_repository/migrations/16.0.1.3.0/post-migration.py b/odoo_repository/migrations/16.0.1.3.0/post-migration.py deleted file mode 100644 index 88da69e6..00000000 --- a/odoo_repository/migrations/16.0.1.3.0/post-migration.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not version: - return - set_manual_branches_on_odoo_repository(cr) - - -def set_manual_branches_on_odoo_repository(cr): - _logger.info("Set 'manual_branches = True' on specific repositories...") - query = """ - UPDATE odoo_repository - SET manual_branches = true - WHERE specific = true; - """ - cr.execute(query) diff --git a/odoo_repository/migrations/16.0.1.3.1/post-migration.py b/odoo_repository/migrations/16.0.1.3.1/post-migration.py deleted file mode 100644 index 2edc0532..00000000 --- a/odoo_repository/migrations/16.0.1.3.1/post-migration.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2025 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -import logging - -from odoo import SUPERUSER_ID, api - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not version: - return - env = api.Environment(cr, SUPERUSER_ID, {}) - update_modules_to_depend_on_base(env) - - -def update_modules_to_depend_on_base(env): - _logger.info("Update modules without dependencies to depend on 'base'...") - env["odoo.module.branch"]._update_modules_to_depend_on_base() diff --git a/odoo_repository/migrations/16.0.1.4.1/pre-migration.py b/odoo_repository/migrations/16.0.1.4.1/pre-migration.py deleted file mode 100644 index 1c257165..00000000 --- a/odoo_repository/migrations/16.0.1.4.1/pre-migration.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not version: - return - delete_duplicated_module_versions(cr) - - -def delete_duplicated_module_versions(cr): - _logger.info("Delete duplicated module versions...") - query = """ - DELETE FROM odoo_module_branch_version mbv - WHERE EXISTS( - SELECT 1 - FROM ( - SELECT - id, - module_branch_id, - name, - manifest_value, - ROW_NUMBER() OVER ( - PARTITION BY module_branch_id, name, manifest_value - ORDER BY create_date - ) rn - FROM odoo_module_branch_version - ) t - where t.rn > 1 and t.id = mbv.id - ) - RETURNING mbv.id; - """ - cr.execute(query) - rows = cr.fetchall() - _logger.info("%s duplicated module versions deleted.", len(rows)) diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index ce14f9a4..4e96bc25 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -21,7 +21,7 @@ class OdooModuleBranchVersion(models.Model): store=True, ) module_id = fields.Many2one( - string="Module", + string="Module ", related="module_branch_id.module_id", store=True, index=True, diff --git a/odoo_repository/models/res_company.py b/odoo_repository/models/res_company.py index 17a80c80..355c894a 100644 --- a/odoo_repository/models/res_company.py +++ b/odoo_repository/models/res_company.py @@ -9,7 +9,7 @@ class ResCompany(models.Model): config_odoo_repository_default_token_id = fields.Many2one( comodel_name="authentication.token", - string="Default token", + string="Default Authentication Token", help=( "Default token used to clone repositories and authenticate " "on API like GitHub." diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py index 50376d78..ef16923d 100644 --- a/odoo_repository/models/res_config_settings.py +++ b/odoo_repository/models/res_config_settings.py @@ -19,5 +19,5 @@ class ResConfigSettings(models.TransientModel): readonly=False, ) config_odoo_repository_main_node_url = fields.Char( - string="Endpoint URL", config_parameter="odoo_repository_main_node_url" + string="Main Node", config_parameter="odoo_repository_main_node_url" ) diff --git a/odoo_repository/security/res_groups.xml b/odoo_repository/security/res_groups.xml index 1d664ab6..ca6fa70e 100644 --- a/odoo_repository/security/res_groups.xml +++ b/odoo_repository/security/res_groups.xml @@ -3,17 +3,17 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - Odoo Repository + Odoo MCA 50 - Odoo Repository User + Odoo MCA User - Odoo Repository Manager + Odoo MCA Manager authentication.token.tree authentication.token - + - + diff --git a/odoo_repository/views/menu.xml b/odoo_repository/views/menu.xml index af727c82..ec5d2301 100644 --- a/odoo_repository/views/menu.xml +++ b/odoo_repository/views/menu.xml @@ -6,7 +6,7 @@ id="main_odoo_repository_menu" groups="odoo_repository.group_odoo_repository_user" web_icon="odoo_repository,static/description/icon.png" - name="Odoo Repositories" + name="Odoo MCA" /> odoo.author.tree odoo.author - + - + diff --git a/odoo_repository/views/odoo_branch.xml b/odoo_repository/views/odoo_branch.xml index 62bbfdb3..52f2904c 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -26,18 +26,15 @@ - + - + - + @@ -49,9 +46,9 @@ odoo.branch.tree odoo.branch - + - + diff --git a/odoo_repository/views/odoo_license.xml b/odoo_repository/views/odoo_license.xml index 2d7e781f..93c9ce67 100644 --- a/odoo_repository/views/odoo_license.xml +++ b/odoo_repository/views/odoo_license.xml @@ -6,9 +6,9 @@ odoo.license.tree odoo.license - + - + diff --git a/odoo_repository/views/odoo_maintainer.xml b/odoo_repository/views/odoo_maintainer.xml index 8fe71e2d..9b848478 100644 --- a/odoo_repository/views/odoo_maintainer.xml +++ b/odoo_repository/views/odoo_maintainer.xml @@ -13,7 +13,7 @@ - + @@ -22,7 +22,7 @@ - + @@ -34,10 +34,10 @@ odoo.maintainer.tree odoo.maintainer - + - + diff --git a/odoo_repository/views/odoo_module.xml b/odoo_repository/views/odoo_module.xml index b375f8a8..d59e4b26 100644 --- a/odoo_repository/views/odoo_module.xml +++ b/odoo_repository/views/odoo_module.xml @@ -11,7 +11,7 @@ From 4b4079eb73a8d10b36f6f7f3d09542555ce3f781 Mon Sep 17 00:00:00 2001 From: mymage Date: Tue, 10 Feb 2026 08:52:48 +0000 Subject: [PATCH 137/160] Added translation using Weblate (Italian) --- odoo_repository/i18n/it.po | 1224 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1224 insertions(+) create mode 100644 odoo_repository/i18n/it.po diff --git a/odoo_repository/i18n/it.po b/odoo_repository/i18n/it.po new file mode 100644 index 00000000..d959fe74 --- /dev/null +++ b/odoo_repository/i18n/it.po @@ -0,0 +1,1224 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odoo_repository +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "/var/lib/odoo-repository/" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Dependencies" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Modules" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_view_form +msgid "" +"You can only create blacklisted module names. The scanner is " +"responsible to create technical module names automatically, but will ignore " +"blacklisted modules." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__active +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__active +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__active +msgid "Active" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_repository_addons_path +msgid "Addons" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_repository_addons_path_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__addons_path +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__addons_path_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_repository_addons_path_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Addons Path" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_branch__name +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__branch_name +msgid "" +"An Odoo version is also used as an Odoo branch name in generic repositories " +"(Odoo, OCA...)." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__application +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Application" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_branch_view_search +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_search +msgid "Archived" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_authentication_token +msgid "Authentication Token" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.authentication_token_action +#: model:ir.ui.menu,name:odoo_repository.authentication_token_menu +msgid "Authentication Tokens" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Author" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_author_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__author_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_author_menu +msgid "Authors" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__auto_install +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Auto-Install" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__blacklisted +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_view_search +msgid "Blacklisted" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module__blacklisted +msgid "Blacklisted modules won't be scanned." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Branch" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__branch_name +msgid "Branch Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__branch_sequence +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__branch_sequence +msgid "Branch Sequence" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__branch_ids +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Branches" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__manual_branches +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_branch__manual_branches +msgid "" +"By default repository branches follows the configured Odoo versions (e.g: " +"17.0, 18.0...). Enable this option to configure your own branches." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__sloc_css +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_tree +msgid "CSS" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__sloc_css +msgid "CSS source lines of code" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_category_action +#: model:ir.ui.menu,name:odoo_repository.odoo_module_category_menu +msgid "Categories" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__category_id +msgid "Category" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__clone_name +msgid "Clone Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__clone_url +msgid "Clone URL" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__cloned_branch +msgid "Cloned Branch" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Code analysis" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__commit +msgid "Commit" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Community Contribution" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__is_community +msgid "Community Contribution?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__is_community +msgid "Community?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_res_company +msgid "Companies" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: odoo_repository +#: model:ir.ui.menu,name:odoo_repository.config_odoo_repository_menu +msgid "Configuration" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__manual_branches +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__manual_branches +msgid "Configure branches manually" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__create_uid +msgid "Created by" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__create_date +msgid "Created on" +msgstr "" + +#. module: odoo_repository +#: model:ir.ui.menu,name:odoo_repository.data_odoo_repository_menu +msgid "Data" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_company__config_odoo_repository_default_token_id +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_default_token_id +msgid "Default Authentication Token" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_res_company__config_odoo_repository_default_token_id +#: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_default_token_id +msgid "" +"Default token used to clone repositories and authenticate on API like " +"GitHub." +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_branch_action_recursive_dependencies +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__dependency_ids +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Dependencies" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__non_std_dependency_level +msgid "Dependency level excluding all standard Odoo modules." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__global_dependency_level +msgid "Dependency level including all standard Odoo modules." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__title +msgid "Descriptive name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__development_status_id +msgid "Develoment Status" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_dev_status_action +#: model:ir.ui.menu,name:odoo_repository.odoo_module_dev_status_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Development Status" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__display_name +msgid "Display Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_addons_path__is_enterprise +msgid "" +"Does this folder contain Enterprise modules?\n" +"(from Odoo S.A., a contributor or your organization)" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_addons_path__is_community +msgid "" +"Does this folder contain Odoo generic community modules?\n" +"(from OCA, a contributor or your organization)" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_addons_path__is_standard +msgid "Does this folder contain modules from Odoo S.A.?" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "Endpoint URL of the main node from which modules data can be fetched." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Enterprise" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__is_enterprise +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__is_enterprise +msgid "Enterprise?" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_view_form +msgid "Exist on repositories/branches:" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__external_dependencies +msgid "External Dependencies" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "Fix file system permissions when cloning repositories." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_res_company__config_odoo_repository_workaround_fs_errors +#: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_workaround_fs_errors +msgid "" +"Fix file system permissions when cloning repositories. Errors could be " +"triggered on some file systems when git tries to execute 'chown' commands on" +" its internal configuration files. This option will workaround this issue." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Force Scan" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_branch__cloned_branch +msgid "" +"Force the branch to clone (optional). Used on repositories with 'Configure " +"branches manually' option enabled." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__clone_name +msgid "" +"Force the name of the cloned repository (folder on disk). If not set, the " +"name of the repository will be used." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__full_path +msgid "Full Path" +msgstr "" + +#. module: odoo_repository +#. odoo-python +#: code:addons/odoo_repository/models/odoo_module_branch.py:0 +msgid "" +"Generic module %(generic_mod)s cannot depend on specific module(s): " +"%(specific_mods)s" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields.selection,name:odoo_repository.selection__odoo_repository__repo_type__github +msgid "GitHub" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__github_url +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__github_url +msgid "GitHub URL" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields.selection,name:odoo_repository.selection__odoo_repository__repo_type__gitlab +msgid "GitLab" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__global_dependency_level +msgid "Global Dep. Level" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_search +msgid "Group By" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__has_migration_script +msgid "Has Migration Script" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__specific +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository_branch__specific +msgid "" +"Host specific modules (that are not generic). Used for project repositories." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__id +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__id +msgid "ID" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "" +"If the environment variable GITHUB_TOKEN is set on the " +"running system, it will be used as fallback." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__installable +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Installable" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__is_community +msgid "Is this module a contribution of the community?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__is_enterprise +msgid "Is this module designed for Odoo Enterprise only?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__is_standard +msgid "Is this module part of Odoo standard?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__sloc_js +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_tree +msgid "JS" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__sloc_js +msgid "JavaScript source lines of code" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__last_scanned_commit +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__last_scanned_commit +msgid "Last Scanned Commit" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__write_date +msgid "Last Updated on" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__version +msgid "Last version" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__license_id +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "License" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_license_action +#: model:ir.ui.menu,name:odoo_repository.odoo_license_menu +msgid "Licenses" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__scan_weekday_ids +msgid "" +"Limit scanning of this repository by the scheduled action to certain days " +"only. If not defined, the scan will happen every day." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_main_node_url +msgid "Main Node" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_maintainer_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__module_branch_ids +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__maintainer_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_maintainer_menu +msgid "Maintainers" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Manifest" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__manifest_value +msgid "Manifest Value" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__migration_script_url +msgid "Migration Script" +msgstr "" + +#. module: odoo_repository +#: model:ir.ui.menu,name:odoo_repository.misc_odoo_repository_menu +msgid "Misc." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__module_branch_id +msgid "Module" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__module_id +msgid "Module " +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__module_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__module_name +msgid "Module Technical Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__specific +msgid "" +"Module specific to a project repository.It cannot be used across different " +"projects." +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_branch_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__module_branch_ids +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__module_ids +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__module_ids +#: model:ir.ui.menu,name:odoo_repository.modules_odoo_repository_menu +#: model:ir.ui.menu,name:odoo_repository.odoo_module_branch_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_graph +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_pivot +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_branch_view_form +msgid "Modules" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_branch_report_action +#: model:ir.ui.menu,name:odoo_repository.odoo_module_branch_report_menu +msgid "Modules Analysis" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_maintainer_view_form +msgid "Modules maintained" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_dev_status__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_python_dependency__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_org__name +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__name +msgid "Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__next_id +msgid "Next" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__non_std_dependency_level +msgid "Non-Std Dep. Level" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_branch +msgid "Odoo Branch/Version" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_license +msgid "Odoo License" +msgstr "" + +#. module: odoo_repository +#: model:ir.module.category,name:odoo_repository.module_category_odoo_repository +#: model:ir.ui.menu,name:odoo_repository.main_odoo_repository_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "Odoo MCA" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.server,name:odoo_repository.cron_fetch_data_ir_actions_server +msgid "Odoo MCA - Fetch data from main node" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.server,name:odoo_repository.cron_scanner_ir_actions_server +msgid "Odoo MCA - Scanner" +msgstr "" + +#. module: odoo_repository +#: model:res.groups,name:odoo_repository.group_odoo_repository_manager +msgid "Odoo MCA Manager" +msgstr "" + +#. module: odoo_repository +#: model:res.groups,name:odoo_repository.group_odoo_repository_user +msgid "Odoo MCA User" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_author +msgid "Odoo Module Author" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_module_branch +msgid "Odoo Module Branch" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_module_category +msgid "Odoo Module Category" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_module_dev_status +msgid "Odoo Module Development Status" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_maintainer +msgid "Odoo Module Maintainer" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_module +msgid "Odoo Module Technical Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_repository +msgid "Odoo Modules Repository" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_repository_branch +msgid "Odoo Modules Repository Branch" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_python_dependency +msgid "Odoo Python Dependency" +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_repository_org +msgid "Odoo Repository Organization" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__branch_id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__branch_id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__branch_id +msgid "Odoo Version" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Options" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__org_id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__org_id +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_search +msgid "Organization" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Others" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__pr_url +msgid "PR URL" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__private_key +msgid "Private Key" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__sloc_python +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_tree +msgid "Python" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_python_dependency_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__python_dependency_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_python_dependency_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Python Dependencies" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__sloc_python +msgid "Python source lines of code" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__relative_path +msgid "Relative Path" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__addons_path_ids +msgid "Relative path of folders in this repository hosting Odoo modules" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__removed +msgid "Removed" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__repo_type +msgid "Repo Type" +msgstr "" + +#. module: odoo_repository +#: model:ir.ui.menu,name:odoo_repository.report_odoo_repository_menu +msgid "Report" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_repository_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__repository_branch_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_repository_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_branch_view_form +msgid "Repositories" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__repository_id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__repository_id +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Repository" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__repository_branch_id +msgid "Repository Branch" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_repository_org_action +#: model:ir.ui.menu,name:odoo_repository.odoo_repository_org_menu +msgid "Repository Organizations" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__reverse_dependency_ids +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Reverse Dependencies" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__ssh_key_id +msgid "SSH Key" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.ssh_key_action +#: model:ir.ui.menu,name:odoo_repository.ssh_key_menu +msgid "SSH Keys" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__ssh_key_id +msgid "SSH key used to clone/fetch this repository." +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_ssh_key +msgid "SSH private key" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_ssh_key__private_key +msgid "SSH private key without passphrase." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "Scan" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__to_scan +msgid "Scan this repository to collect data." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__scan_weekday_ids +msgid "Scanning days" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__sequence +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__repository_sequence +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__sequence +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__sequence +msgid "Sequence" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.action_odoo_repository_config_settings +#: model:ir.ui.menu,name:odoo_repository.settings_odoo_repository_menu +msgid "Settings" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__specific +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__specific +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_branch__specific +msgid "Specific" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Standard" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__is_standard +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository_addons_path__is_standard +msgid "Standard?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_storage_path +msgid "Storage local path" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__summary +msgid "Summary" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__name +msgid "Techname" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_module_action +#: model:ir.ui.menu,name:odoo_repository.odoo_module_menu +msgid "Technical Module Names" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module__name +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__module_name +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch_version__module_name +msgid "Technical Name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch_version__manifest_value +msgid "Technical field to host the manifest value." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__addons_path +msgid "Technical field. Where the module is located in the repository." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__module_id +msgid "Technical name" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_python_dependency_name_uniq +msgid "This Python dependency already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_repository_addons_path_addons_path_uniq +msgid "This addons-path already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_author_name_uniq +msgid "This author already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_repository_branch_repository_id_branch_id_uniq +msgid "This branch already exists for this repository." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_branch_name_uniq +msgid "This branch already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_module_dev_status_name_uniq +msgid "This development_status already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_license_name_uniq +msgid "This license already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_maintainer_name_uniq +msgid "This maintainer already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_module_branch_module_id_branch_id_repository_id_uniq +msgid "This module already exists for this repository/branch." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_module_category_name_uniq +msgid "This module category already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_module_name_uniq +msgid "This module technical name already exists." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_branch_view_form +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_form +msgid "This operation will restart the scan from the beginning. Are you sure?" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_repository_org_name_uniq +msgid "This organization already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_repository_org_id_name_uniq +msgid "This repository already exists." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.constraint,message:odoo_repository.constraint_odoo_module_branch_version_module_branch_id_name_manifest_value_uniq +msgid "This version already exists for this module." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__title +msgid "Title" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__to_scan +msgid "To Scan" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_repository_view_search +msgid "To scan" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_authentication_token__token +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__token_id +msgid "Token" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__token_id +msgid "Token used to clone/fetch this repository." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__url +msgid "URL" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Unmerged" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "Used to clone repositories and authenticate on API like GitHub." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__clone_url +msgid "Used to clone the repository." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__name +msgid "Version" +msgstr "" + +#. module: odoo_repository +#. odoo-python +#: code:addons/odoo_repository/models/odoo_branch.py:0 +msgid "Version must match the pattern 'x.y'." +msgstr "" + +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_module_branch_version +msgid "Version of a Odoo Module on a given branch" +msgstr "" + +#. module: odoo_repository +#: model:ir.actions.act_window,name:odoo_repository.odoo_branch_action +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__version_ids +#: model:ir.ui.menu,name:odoo_repository.odoo_branch_menu +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_form +msgid "Versions" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_repository__repo_url +msgid "Web URL" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_repository__repo_url +msgid "Web access to this repository." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "Where to clone repositories on the local filesystem." +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "With Repository" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_search +msgid "Without Repository" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_company__config_odoo_repository_workaround_fs_errors +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_workaround_fs_errors +msgid "Workaround FS errors" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__sloc_xml +#: model_terms:ir.ui.view,arch_db:odoo_repository.odoo_module_branch_view_tree +msgid "XML" +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_odoo_module_branch__sloc_xml +msgid "XML source lines of code" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "https://example.net/odoo-repository/data" +msgstr "" From 649b15a9e36979e350a1d6272ca46fd098f3ef6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 16 Feb 2026 08:11:58 +0100 Subject: [PATCH 138/160] [IMP] odoo_repository: sync OCA repositories Do not maintain the list of OCA repositories manually anymore, but sync it automatically with https://github.com/OCA/repo-maintainer-conf. All repositories having a default branch set to `master` or `main` will be skipped (such repositories are not hosting Odoo modules). Others like OpenUpgrade or OCB will be skipped too. --- odoo_repository/__init__.py | 1 + odoo_repository/__manifest__.py | 9 +- odoo_repository/components/__init__.py | 1 + .../components/oca_repository_synchronizer.py | 217 ++++++++++++++ odoo_repository/data/ir_config_parameter.xml | 9 + odoo_repository/data/ir_cron.xml | 15 + odoo_repository/data/odoo.repository.csv | 216 -------------- odoo_repository/data/odoo_mca_backend.xml | 8 + odoo_repository/data/queue_job.xml | 11 + .../migrations/18.0.1.1.0/pre-migration.py | 48 +++ odoo_repository/models/__init__.py | 1 + odoo_repository/models/odoo_mca_backend.py | 17 ++ odoo_repository/models/odoo_repository.py | 31 ++ odoo_repository/models/res_config_settings.py | 9 + odoo_repository/readme/DESCRIPTION.md | 2 +- odoo_repository/security/ir.model.access.csv | 1 + odoo_repository/tests/__init__.py | 1 + .../tests/test_oca_repository_synchronizer.py | 280 ++++++++++++++++++ .../tests/test_odoo_module_branch.py | 1 + odoo_repository/views/res_config_settings.xml | 10 + 20 files changed, 669 insertions(+), 219 deletions(-) create mode 100644 odoo_repository/components/__init__.py create mode 100644 odoo_repository/components/oca_repository_synchronizer.py create mode 100644 odoo_repository/data/ir_config_parameter.xml delete mode 100644 odoo_repository/data/odoo.repository.csv create mode 100644 odoo_repository/data/odoo_mca_backend.xml create mode 100644 odoo_repository/migrations/18.0.1.1.0/pre-migration.py create mode 100644 odoo_repository/models/odoo_mca_backend.py create mode 100644 odoo_repository/tests/test_oca_repository_synchronizer.py diff --git a/odoo_repository/__init__.py b/odoo_repository/__init__.py index f7209b17..5f713a85 100644 --- a/odoo_repository/__init__.py +++ b/odoo_repository/__init__.py @@ -1,2 +1,3 @@ from . import models +from . import components from . import controllers diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 8cbc14c8..6998f8e6 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -1,9 +1,10 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", @@ -15,9 +16,10 @@ "data/odoo_repository_org.xml", "data/odoo_repository_addons_path.xml", "data/odoo_repository.xml", - "data/odoo.repository.csv", "data/odoo_branch.xml", "data/queue_job.xml", + "data/odoo_mca_backend.xml", + "data/ir_config_parameter.xml", "views/menu.xml", "views/authentication_token.xml", "views/ssh_key.xml", @@ -45,11 +47,14 @@ "base_time_window", # OCA/queue "queue_job", + # OCA/connector + "component", ], "external_dependencies": { "python": [ "gitpython", "odoo-addons-parser", + "pyyaml", # TODO to publish # "odoo-repository-scanner" ], diff --git a/odoo_repository/components/__init__.py b/odoo_repository/components/__init__.py new file mode 100644 index 00000000..f8f38056 --- /dev/null +++ b/odoo_repository/components/__init__.py @@ -0,0 +1 @@ +from . import oca_repository_synchronizer diff --git a/odoo_repository/components/oca_repository_synchronizer.py b/odoo_repository/components/oca_repository_synchronizer.py new file mode 100644 index 00000000..b56e37e7 --- /dev/null +++ b/odoo_repository/components/oca_repository_synchronizer.py @@ -0,0 +1,217 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +import requests +import yaml + +from odoo import Command + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +from ..utils import github + +_logger = logging.getLogger(__name__) + + +class OCARepositorySynchronizer(Component): + """Component for synchronizing OCA repositories from GitHub configuration. + + This component is syncing the list of OCA repositories in local database + from https://github.com/OCA/repo-maintainer-conf repository. + It creates new repositories, updates existing ones, and archive those + no longer declared in the OCA configuration. + """ + + _name = "oca.repository.synchronizer" + _collection = "odoo.mca.backend" + _usage = "oca.repository.synchronizer" + + github_conf_api_url = "repos/OCA/repo-maintainer-conf/contents/conf/repo" + + def _get_oca_repo_blacklist(self): + """Get OCA repository blacklist from configuration parameter. + + Returns: + list: List of repository names to skip during synchronization + """ + param_value = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("odoo_repository.oca_repo_blacklist", default="") + ) + return [repo.strip() for repo in param_value.split(",") if repo.strip()] + + def run(self): + """Execute OCA repository synchronization. + + Fetches repository configurations from OCA/repo-maintainer-conf, + parses the YAML files, and synchronizes the local repository database by: + - Creating new repositories that exist in OCA config but not locally + - Updating existing repositories with any changes + - Archiving repositories that no longer exist in OCA config + """ + try: + # Fetch YAML configurations from GitHub + repo_configs = self._fetch_oca_repo_configurations() + # Parse and extract repository data + current_repos = self._parse_oca_repo_configurations(repo_configs) + # Sync repositories (create/update/archive) + stats = self._sync_oca_repositories(current_repos) + _logger.info("OCA repository sync completed: %s", stats) + return stats + except Exception as e: + raise RetryableJobError("Failed to fetch OCA repositories") from e + + def _fetch_oca_repo_configurations(self): + """Fetch OCA repository configurations from GitHub. + + Returns: + dict: Repository configurations keyed by filename + """ + try: + # Get list of files in conf/repo directory + files = github.request(self.work.env, self.github_conf_api_url) + repo_configs = {} + for file_info in files: + if file_info["name"].endswith(".yml"): + # Fetch the actual YAML content + download_url = file_info["download_url"] + response = requests.get(download_url, timeout=30) + response.raise_for_status() + repo_configs[file_info["name"]] = response.text + return repo_configs + except Exception as e: + _logger.error("Failed to fetch OCA repo configurations: %s", str(e)) + raise + + def _parse_oca_repo_configurations(self, repo_configs: dict): + """Parse YAML configurations and extract OCA repository data. + + Args: + repo_configs: Dict of filename -> YAML content + + Returns: + dict: Repository data keyed by repository name, only including + repositories with non-empty branches + """ + current_repos = {} + for filename, yaml_content in repo_configs.items(): + try: + # Parse YAML content + data = yaml.safe_load(yaml_content) + if not data: + continue + # Process each repository in the YAML file + for repo_name, repo_config in data.items(): + # Get blacklist from backend configuration + blacklist = self._get_oca_repo_blacklist() + if repo_name in blacklist: + _logger.info("Skipping import of OCA repository %s", repo_name) + continue + # Skip repositories with default_branch = 'master' or 'main' + # These are not hosting Odoo modules + default_branch = repo_config.get("default_branch", "") + if default_branch in ["master", "main"]: + _logger.info( + "Skipping OCA repository %s " + "(default_branch=%s, not hosting Odoo modules)", + repo_name, + default_branch, + ) + continue + # Only include repositories with branches. + # Others repositories are probably not hosting Odoo modules. + branches = repo_config.get("branches", []) + if not branches: + continue + # Construct repository URLs + repo_url = f"https://github.com/OCA/{repo_name}" # noqa: E231 + current_repos[repo_name] = { + "name": repo_name, + "repo_url": repo_url, + "clone_url": repo_url, + "repo_type": "github", + } + except yaml.YAMLError as e: + _logger.warning("Failed to parse YAML file %s: %s", filename, str(e)) + continue + except Exception as e: + _logger.warning("Error processing file %s: %s", filename, str(e)) + continue + return current_repos + + def _sync_oca_repositories(self, current_repos): + """Synchronize OCA repositories with current configuration. + + Args: + current_repos: Dict of repository data from OCA configuration + + Returns: + dict: Statistics about created/updated/archived repositories + """ + stats = {"created": 0, "updated": 0, "archived": 0} + env = self.work.env + # Get OCA organization + oca_org = env.ref( + "odoo_repository.odoo_repository_org_oca", raise_if_not_found=False + ) + if not oca_org: + _logger.warning("OCA organization not found in database") + return stats + # Get existing OCA repositories + odoo_repository = env["odoo.repository"] + existing_repos = odoo_repository.with_context(active_test=False).search( + [("org_id", "=", oca_org.id)] + ) + # Process current repositories + for repo_name, repo_data in current_repos.items(): + # Check if repository already exists + repo = existing_repos.filtered( + lambda r, repo_name=repo_name: r.name == repo_name + ) + if repo: + # Update existing repository + update_vals = self._prepare_oca_repository_update_vals(repo_data) + # Only update if values have changed + if any(repo[field] != update_vals[field] for field in update_vals): + repo.write(update_vals) + stats["updated"] += 1 + else: + # Create new repository + create_vals = self._prepare_oca_repository_create_vals(repo_data) + odoo_repository.create(create_vals) + stats["created"] += 1 + # Archive repositories that no longer exist in configuration + for repo in existing_repos: + if repo.name not in current_repos and repo.active: + repo.active = repo.to_scan = False + stats["archived"] += 1 + return stats + + def _prepare_oca_repository_update_vals(self, repo_data): + return { + "repo_url": repo_data["repo_url"], + "clone_url": repo_data["clone_url"], + "repo_type": repo_data["repo_type"], + "active": True, + } + + def _prepare_oca_repository_create_vals(self, repo_data): + oca_org = self.work.env.ref("odoo_repository.odoo_repository_org_oca") + addons_path_community = self.work.env.ref( + "odoo_repository.odoo_repository_addons_path_community" + ) + return { + "org_id": oca_org.id, + "name": repo_data["name"], + "repo_url": repo_data["repo_url"], + "clone_url": repo_data["clone_url"], + "repo_type": repo_data["repo_type"], + "active": True, + "sequence": 200, + "to_scan": True, # Enable scanning by default + "addons_path_ids": [Command.link(addons_path_community.id)], + } diff --git a/odoo_repository/data/ir_config_parameter.xml b/odoo_repository/data/ir_config_parameter.xml new file mode 100644 index 00000000..6fcef251 --- /dev/null +++ b/odoo_repository/data/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + odoo_repository.oca_repo_blacklist + OCB,OpenUpgrade + + diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 9a55c900..cc1f4667 100644 --- a/odoo_repository/data/ir_cron.xml +++ b/odoo_repository/data/ir_cron.xml @@ -1,5 +1,6 @@ @@ -29,4 +30,18 @@ code model.cron_fetch_data() + + + Odoo Repositories - Sync OCA repositories + 1 + weeks + + + + code + model.cron_sync_oca_repositories() + diff --git a/odoo_repository/data/odoo.repository.csv b/odoo_repository/data/odoo.repository.csv deleted file mode 100644 index 652e0c9a..00000000 --- a/odoo_repository/data/odoo.repository.csv +++ /dev/null @@ -1,216 +0,0 @@ -id,org_id:id,name,repo_url,clone_url,repo_type -repo_oca_account_analytic,odoo_repository_org_oca,account-analytic,https://github.com/OCA/account-analytic,https://github.com/OCA/account-analytic,github -repo_oca_account_budgeting,odoo_repository_org_oca,account-budgeting,https://github.com/OCA/account-budgeting,https://github.com/OCA/account-budgeting,github -repo_oca_account_closing,odoo_repository_org_oca,account-closing,https://github.com/OCA/account-closing,https://github.com/OCA/account-closing,github -repo_oca_account_consolidation,odoo_repository_org_oca,account-consolidation,https://github.com/OCA/account-consolidation,https://github.com/OCA/account-consolidation,github -repo_oca_account_financial_reporting,odoo_repository_org_oca,account-financial-reporting,https://github.com/OCA/account-financial-reporting,https://github.com/OCA/account-financial-reporting,github -repo_oca_account_financial_tools,odoo_repository_org_oca,account-financial-tools,https://github.com/OCA/account-financial-tools,https://github.com/OCA/account-financial-tools,github -repo_oca_account_fiscal_rule,odoo_repository_org_oca,account-fiscal-rule,https://github.com/OCA/account-fiscal-rule,https://github.com/OCA/account-fiscal-rule,github -repo_oca_account_invoice_reporting,odoo_repository_org_oca,account-invoice-reporting,https://github.com/OCA/account-invoice-reporting,https://github.com/OCA/account-invoice-reporting,github -repo_oca_account_invoicing,odoo_repository_org_oca,account-invoicing,https://github.com/OCA/account-invoicing,https://github.com/OCA/account-invoicing,github -repo_oca_account_payment,odoo_repository_org_oca,account-payment,https://github.com/OCA/account-payment,https://github.com/OCA/account-payment,github -repo_oca_account_reconcile,odoo_repository_org_oca,account-reconcile,https://github.com/OCA/account-reconcile,https://github.com/OCA/account-reconcile,github -repo_oca_agreement,odoo_repository_org_oca,agreement,https://github.com/OCA/agreement,https://github.com/OCA/agreement,github -repo_oca_apps_store,odoo_repository_org_oca,apps-store,https://github.com/OCA/apps-store,https://github.com/OCA/apps-store,github -repo_oca_bank_payment,odoo_repository_org_oca,bank-payment,https://github.com/OCA/bank-payment,https://github.com/OCA/bank-payment,github -repo_oca_bank_payment_alternative,odoo_repository_org_oca,bank-payment-alternative,https://github.com/OCA/bank-payment-alternative,https://github.com/OCA/bank-payment-alternative,github -repo_oca_bank_statement_import,odoo_repository_org_oca,bank-statement-import,https://github.com/OCA/bank-statement-import,https://github.com/OCA/bank-statement-import,github -repo_oca_brand,odoo_repository_org_oca,brand,https://github.com/OCA/brand,https://github.com/OCA/brand,github -repo_oca_business_requirement,odoo_repository_org_oca,business-requirement,https://github.com/OCA/business-requirement,https://github.com/OCA/business-requirement,github -repo_oca_calendar,odoo_repository_org_oca,calendar,https://github.com/OCA/calendar,https://github.com/OCA/calendar,github -repo_oca_commission,odoo_repository_org_oca,commission,https://github.com/OCA/commission,https://github.com/OCA/commission,github -repo_oca_community_data_files,odoo_repository_org_oca,community-data-files,https://github.com/OCA/community-data-files,https://github.com/OCA/community-data-files,github -repo_oca_connector,odoo_repository_org_oca,connector,https://github.com/OCA/connector,https://github.com/OCA/connector,github -repo_oca_connector_accountedge,odoo_repository_org_oca,connector-accountedge,https://github.com/OCA/connector-accountedge,https://github.com/OCA/connector-accountedge,github -repo_oca_connector_cmis,odoo_repository_org_oca,connector-cmis,https://github.com/OCA/connector-cmis,https://github.com/OCA/connector-cmis,github -repo_oca_connector_ecommerce,odoo_repository_org_oca,connector-ecommerce,https://github.com/OCA/connector-ecommerce,https://github.com/OCA/connector-ecommerce,github -repo_oca_connector_infor,odoo_repository_org_oca,connector-infor,https://github.com/OCA/connector-infor,https://github.com/OCA/connector-infor,github -repo_oca_connector_interfaces,odoo_repository_org_oca,connector-interfaces,https://github.com/OCA/connector-interfaces,https://github.com/OCA/connector-interfaces,github -repo_oca_connector_jira,odoo_repository_org_oca,connector-jira,https://github.com/OCA/connector-jira,https://github.com/OCA/connector-jira,github -repo_oca_connector_lengow,odoo_repository_org_oca,connector-lengow,https://github.com/OCA/connector-lengow,https://github.com/OCA/connector-lengow,github -repo_oca_connector_lims,odoo_repository_org_oca,connector-lims,https://github.com/OCA/connector-lims,https://github.com/OCA/connector-lims,github -repo_oca_connector_magento,odoo_repository_org_oca,connector-magento,https://github.com/OCA/connector-magento,https://github.com/OCA/connector-magento,github -repo_oca_connector_magento_php_extension,odoo_repository_org_oca,connector-magento-php-extension,https://github.com/OCA/connector-magento-php-extension,https://github.com/OCA/connector-magento-php-extension,github -repo_oca_connector_odoo2odoo,odoo_repository_org_oca,connector-odoo2odoo,https://github.com/OCA/connector-odoo2odoo,https://github.com/OCA/connector-odoo2odoo,github -repo_oca_connector_prestashop,odoo_repository_org_oca,connector-prestashop,https://github.com/OCA/connector-prestashop,https://github.com/OCA/connector-prestashop,github -repo_oca_connector_redmine,odoo_repository_org_oca,connector-redmine,https://github.com/OCA/connector-redmine,https://github.com/OCA/connector-redmine,github -repo_oca_connector_sage,odoo_repository_org_oca,connector-sage,https://github.com/OCA/connector-sage,https://github.com/OCA/connector-sage,github -repo_oca_connector_salesforce,odoo_repository_org_oca,connector-salesforce,https://github.com/OCA/connector-salesforce,https://github.com/OCA/connector-salesforce,github -repo_oca_connector_spscommerce,odoo_repository_org_oca,connector-spscommerce,https://github.com/OCA/connector-spscommerce,https://github.com/OCA/connector-spscommerce,github -repo_oca_connector_telephony,odoo_repository_org_oca,connector-telephony,https://github.com/OCA/connector-telephony,https://github.com/OCA/connector-telephony,github -repo_oca_connector_woocommerce,odoo_repository_org_oca,connector-woocommerce,https://github.com/OCA/connector-woocommerce,https://github.com/OCA/connector-woocommerce,github -repo_oca_contract,odoo_repository_org_oca,contract,https://github.com/OCA/contract,https://github.com/OCA/contract,github -repo_oca_cooperative,odoo_repository_org_oca,cooperative,https://github.com/OCA/cooperative,https://github.com/OCA/cooperative,github -repo_oca_credit_control,odoo_repository_org_oca,credit-control,https://github.com/OCA/credit-control,https://github.com/OCA/credit-control,github -repo_oca_crm,odoo_repository_org_oca,crm,https://github.com/OCA/crm,https://github.com/OCA/crm,github -repo_oca_currency,odoo_repository_org_oca,currency,https://github.com/OCA/currency,https://github.com/OCA/currency,github -repo_oca_data_protection,odoo_repository_org_oca,data-protection,https://github.com/OCA/data-protection,https://github.com/OCA/data-protection,github -repo_oca_ddmrp,odoo_repository_org_oca,ddmrp,https://github.com/OCA/ddmrp,https://github.com/OCA/ddmrp,github -repo_oca_delivery_carrier,odoo_repository_org_oca,delivery-carrier,https://github.com/OCA/delivery-carrier,https://github.com/OCA/delivery-carrier,github -repo_oca_department,odoo_repository_org_oca,department,https://github.com/OCA/department,https://github.com/OCA/department,github -repo_oca_dms,odoo_repository_org_oca,dms,https://github.com/OCA/dms,https://github.com/OCA/dms,github -repo_oca_donation,odoo_repository_org_oca,donation,https://github.com/OCA/donation,https://github.com/OCA/donation,github -repo_oca_e_commerce,odoo_repository_org_oca,e-commerce,https://github.com/OCA/e-commerce,https://github.com/OCA/e-commerce,github -repo_oca_e_learning,odoo_repository_org_oca,e-learning,https://github.com/OCA/e-learning,https://github.com/OCA/e-learning,github -repo_oca_edi,odoo_repository_org_oca,edi,https://github.com/OCA/edi,https://github.com/OCA/edi,github -repo_oca_edi_framework,odoo_repository_org_oca,edi-framework,https://github.com/OCA/edi-framework,https://github.com/OCA/edi-framework,github -repo_oca_event,odoo_repository_org_oca,event,https://github.com/OCA/event,https://github.com/OCA/event,github -repo_oca_field_service,odoo_repository_org_oca,field-service,https://github.com/OCA/field-service,https://github.com/OCA/field-service,github -repo_oca_fleet,odoo_repository_org_oca,fleet,https://github.com/OCA/fleet,https://github.com/OCA/fleet,github -repo_oca_geospatial,odoo_repository_org_oca,geospatial,https://github.com/OCA/geospatial,https://github.com/OCA/geospatial,github -repo_oca_helpdesk,odoo_repository_org_oca,helpdesk,https://github.com/OCA/helpdesk,https://github.com/OCA/helpdesk,github -repo_oca_hr,odoo_repository_org_oca,hr,https://github.com/OCA/hr,https://github.com/OCA/hr,github -repo_oca_hr_attendance,odoo_repository_org_oca,hr-attendance,https://github.com/OCA/hr-attendance,https://github.com/OCA/hr-attendance,github -repo_oca_hr_expense,odoo_repository_org_oca,hr-expense,https://github.com/OCA/hr-expense,https://github.com/OCA/hr-expense,github -repo_oca_hr_holidays,odoo_repository_org_oca,hr-holidays,https://github.com/OCA/hr-holidays,https://github.com/OCA/hr-holidays,github -repo_oca_infrastructure,odoo_repository_org_oca,infrastructure,https://github.com/OCA/infrastructure,https://github.com/OCA/infrastructure,github -repo_oca_interface_github,odoo_repository_org_oca,interface-github,https://github.com/OCA/interface-github,https://github.com/OCA/interface-github,github -repo_oca_intrastat_extrastat,odoo_repository_org_oca,intrastat-extrastat,https://github.com/OCA/intrastat-extrastat,https://github.com/OCA/intrastat-extrastat,github -repo_oca_iot,odoo_repository_org_oca,iot,https://github.com/OCA/iot,https://github.com/OCA/iot,github -repo_oca_knowledge,odoo_repository_org_oca,knowledge,https://github.com/OCA/knowledge,https://github.com/OCA/knowledge,github -repo_oca_l10n_argentina,odoo_repository_org_oca,l10n-argentina,https://github.com/OCA/l10n-argentina,https://github.com/OCA/l10n-argentina,github -repo_oca_l10n_austria,odoo_repository_org_oca,l10n-austria,https://github.com/OCA/l10n-austria,https://github.com/OCA/l10n-austria,github -repo_oca_l10n_belarus,odoo_repository_org_oca,l10n-belarus,https://github.com/OCA/l10n-belarus,https://github.com/OCA/l10n-belarus,github -repo_oca_l10n_belgium,odoo_repository_org_oca,l10n-belgium,https://github.com/OCA/l10n-belgium,https://github.com/OCA/l10n-belgium,github -repo_oca_l10n_brazil,odoo_repository_org_oca,l10n-brazil,https://github.com/OCA/l10n-brazil,https://github.com/OCA/l10n-brazil,github -repo_oca_l10n_cambodia,odoo_repository_org_oca,l10n-cambodia,https://github.com/OCA/l10n-cambodia,https://github.com/OCA/l10n-cambodia,github -repo_oca_l10n_canada,odoo_repository_org_oca,l10n-canada,https://github.com/OCA/l10n-canada,https://github.com/OCA/l10n-canada,github -repo_oca_l10n_chile,odoo_repository_org_oca,l10n-chile,https://github.com/OCA/l10n-chile,https://github.com/OCA/l10n-chile,github -repo_oca_l10n_china,odoo_repository_org_oca,l10n-china,https://github.com/OCA/l10n-china,https://github.com/OCA/l10n-china,github -repo_oca_l10n_colombia,odoo_repository_org_oca,l10n-colombia,https://github.com/OCA/l10n-colombia,https://github.com/OCA/l10n-colombia,github -repo_oca_l10n_costa_rica,odoo_repository_org_oca,l10n-costa-rica,https://github.com/OCA/l10n-costa-rica,https://github.com/OCA/l10n-costa-rica,github -repo_oca_l10n_croatia,odoo_repository_org_oca,l10n-croatia,https://github.com/OCA/l10n-croatia,https://github.com/OCA/l10n-croatia,github -repo_oca_l10n_ecuador,odoo_repository_org_oca,l10n-ecuador,https://github.com/OCA/l10n-ecuador,https://github.com/OCA/l10n-ecuador,github -repo_oca_l10n_estonia,odoo_repository_org_oca,l10n-estonia,https://github.com/OCA/l10n-estonia,https://github.com/OCA/l10n-estonia,github -repo_oca_l10n_ethiopia,odoo_repository_org_oca,l10n-ethiopia,https://github.com/OCA/l10n-ethiopia,https://github.com/OCA/l10n-ethiopia,github -repo_oca_l10n_finland,odoo_repository_org_oca,l10n-finland,https://github.com/OCA/l10n-finland,https://github.com/OCA/l10n-finland,github -repo_oca_l10n_france,odoo_repository_org_oca,l10n-france,https://github.com/OCA/l10n-france,https://github.com/OCA/l10n-france,github -repo_oca_l10n_germany,odoo_repository_org_oca,l10n-germany,https://github.com/OCA/l10n-germany,https://github.com/OCA/l10n-germany,github -repo_oca_l10n_greece,odoo_repository_org_oca,l10n-greece,https://github.com/OCA/l10n-greece,https://github.com/OCA/l10n-greece,github -repo_oca_l10n_india,odoo_repository_org_oca,l10n-india,https://github.com/OCA/l10n-india,https://github.com/OCA/l10n-india,github -repo_oca_l10n_indonesia,odoo_repository_org_oca,l10n-indonesia,https://github.com/OCA/l10n-indonesia,https://github.com/OCA/l10n-indonesia,github -repo_oca_l10n_iran,odoo_repository_org_oca,l10n-iran,https://github.com/OCA/l10n-iran,https://github.com/OCA/l10n-iran,github -repo_oca_l10n_ireland,odoo_repository_org_oca,l10n-ireland,https://github.com/OCA/l10n-ireland,https://github.com/OCA/l10n-ireland,github -repo_oca_l10n_italy,odoo_repository_org_oca,l10n-italy,https://github.com/OCA/l10n-italy,https://github.com/OCA/l10n-italy,github -repo_oca_l10n_japan,odoo_repository_org_oca,l10n-japan,https://github.com/OCA/l10n-japan,https://github.com/OCA/l10n-japan,github -repo_oca_l10n_luxemburg,odoo_repository_org_oca,l10n-luxemburg,https://github.com/OCA/l10n-luxemburg,https://github.com/OCA/l10n-luxemburg,github -repo_oca_l10n_macedonia,odoo_repository_org_oca,l10n-macedonia,https://github.com/OCA/l10n-macedonia,https://github.com/OCA/l10n-macedonia,github -repo_oca_l10n_mexico,odoo_repository_org_oca,l10n-mexico,https://github.com/OCA/l10n-mexico,https://github.com/OCA/l10n-mexico,github -repo_oca_l10n_morocco,odoo_repository_org_oca,l10n-morocco,https://github.com/OCA/l10n-morocco,https://github.com/OCA/l10n-morocco,github -repo_oca_l10n_netherlands,odoo_repository_org_oca,l10n-netherlands,https://github.com/OCA/l10n-netherlands,https://github.com/OCA/l10n-netherlands,github -repo_oca_l10n_norway,odoo_repository_org_oca,l10n-norway,https://github.com/OCA/l10n-norway,https://github.com/OCA/l10n-norway,github -repo_oca_l10n_peru,odoo_repository_org_oca,l10n-peru,https://github.com/OCA/l10n-peru,https://github.com/OCA/l10n-peru,github -repo_oca_l10n_poland,odoo_repository_org_oca,l10n-poland,https://github.com/OCA/l10n-poland,https://github.com/OCA/l10n-poland,github -repo_oca_l10n_portugal,odoo_repository_org_oca,l10n-portugal,https://github.com/OCA/l10n-portugal,https://github.com/OCA/l10n-portugal,github -repo_oca_l10n_romania,odoo_repository_org_oca,l10n-romania,https://github.com/OCA/l10n-romania,https://github.com/OCA/l10n-romania,github -repo_oca_l10n_russia,odoo_repository_org_oca,l10n-russia,https://github.com/OCA/l10n-russia,https://github.com/OCA/l10n-russia,github -repo_oca_l10n_slovenia,odoo_repository_org_oca,l10n-slovenia,https://github.com/OCA/l10n-slovenia,https://github.com/OCA/l10n-slovenia,github -repo_oca_l10n_spain,odoo_repository_org_oca,l10n-spain,https://github.com/OCA/l10n-spain,https://github.com/OCA/l10n-spain,github -repo_oca_l10n_switzerland,odoo_repository_org_oca,l10n-switzerland,https://github.com/OCA/l10n-switzerland,https://github.com/OCA/l10n-switzerland,github -repo_oca_l10n_taiwan,odoo_repository_org_oca,l10n-taiwan,https://github.com/OCA/l10n-taiwan,https://github.com/OCA/l10n-taiwan,github -repo_oca_l10n_thailand,odoo_repository_org_oca,l10n-thailand,https://github.com/OCA/l10n-thailand,https://github.com/OCA/l10n-thailand,github -repo_oca_l10n_turkey,odoo_repository_org_oca,l10n-turkey,https://github.com/OCA/l10n-turkey,https://github.com/OCA/l10n-turkey,github -repo_oca_l10n_ukraine,odoo_repository_org_oca,l10n-ukraine,https://github.com/OCA/l10n-ukraine,https://github.com/OCA/l10n-ukraine,github -repo_oca_l10n_united_kingdom,odoo_repository_org_oca,l10n-united-kingdom,https://github.com/OCA/l10n-united-kingdom,https://github.com/OCA/l10n-united-kingdom,github -repo_oca_l10n_uruguay,odoo_repository_org_oca,l10n-uruguay,https://github.com/OCA/l10n-uruguay,https://github.com/OCA/l10n-uruguay,github -repo_oca_l10n_usa,odoo_repository_org_oca,l10n-usa,https://github.com/OCA/l10n-usa,https://github.com/OCA/l10n-usa,github -repo_oca_l10n_venezuela,odoo_repository_org_oca,l10n-venezuela,https://github.com/OCA/l10n-venezuela,https://github.com/OCA/l10n-venezuela,github -repo_oca_l10n_vietnam,odoo_repository_org_oca,l10n-vietnam,https://github.com/OCA/l10n-vietnam,https://github.com/OCA/l10n-vietnam,github -repo_oca_mail,odoo_repository_org_oca,mail,https://github.com/OCA/mail,https://github.com/OCA/mail,github -repo_oca_maintenance,odoo_repository_org_oca,maintenance,https://github.com/OCA/maintenance,https://github.com/OCA/maintenance,github -repo_oca_management_system,odoo_repository_org_oca,management-system,https://github.com/OCA/management-system,https://github.com/OCA/management-system,github -repo_oca_manufacture,odoo_repository_org_oca,manufacture,https://github.com/OCA/manufacture,https://github.com/OCA/manufacture,github -repo_oca_manufacture_reporting,odoo_repository_org_oca,manufacture-reporting,https://github.com/OCA/manufacture-reporting,https://github.com/OCA/manufacture-reporting,github -repo_oca_margin_analysis,odoo_repository_org_oca,margin-analysis,https://github.com/OCA/margin-analysis,https://github.com/OCA/margin-analysis,github -repo_oca_mis_builder,odoo_repository_org_oca,mis-builder,https://github.com/OCA/mis-builder,https://github.com/OCA/mis-builder,github -repo_oca_mis_builder_contrib,odoo_repository_org_oca,mis-builder-contrib,https://github.com/OCA/mis-builder-contrib,https://github.com/OCA/mis-builder-contrib,github -repo_oca_multi_company,odoo_repository_org_oca,multi-company,https://github.com/OCA/multi-company,https://github.com/OCA/multi-company,github -repo_oca_oca_custom,odoo_repository_org_oca,oca-custom,https://github.com/OCA/oca-custom,https://github.com/OCA/oca-custom,github -repo_oca_odoo_pim,odoo_repository_org_oca,odoo-pim,https://github.com/OCA/odoo-pim,https://github.com/OCA/odoo-pim,github -repo_oca_operating_unit,odoo_repository_org_oca,operating-unit,https://github.com/OCA/operating-unit,https://github.com/OCA/operating-unit,github -repo_oca_partner_contact,odoo_repository_org_oca,partner-contact,https://github.com/OCA/partner-contact,https://github.com/OCA/partner-contact,github -repo_oca_payroll,odoo_repository_org_oca,payroll,https://github.com/OCA/payroll,https://github.com/OCA/payroll,github -repo_oca_pms,odoo_repository_org_oca,pms,https://github.com/OCA/pms,https://github.com/OCA/pms,github -repo_oca_pos,odoo_repository_org_oca,pos,https://github.com/OCA/pos,https://github.com/OCA/pos,github -repo_oca_product_attribute,odoo_repository_org_oca,product-attribute,https://github.com/OCA/product-attribute,https://github.com/OCA/product-attribute,github -repo_oca_product_configurator,odoo_repository_org_oca,product-configurator,https://github.com/OCA/product-configurator,https://github.com/OCA/product-configurator,github -repo_oca_product_kitting,odoo_repository_org_oca,product-kitting,https://github.com/OCA/product-kitting,https://github.com/OCA/product-kitting,github -repo_oca_product_pack,odoo_repository_org_oca,product-pack,https://github.com/OCA/product-pack,https://github.com/OCA/product-pack,github -repo_oca_product_variant,odoo_repository_org_oca,product-variant,https://github.com/OCA/product-variant,https://github.com/OCA/product-variant,github -repo_oca_program,odoo_repository_org_oca,program,https://github.com/OCA/program,https://github.com/OCA/program,github -repo_oca_project,odoo_repository_org_oca,project,https://github.com/OCA/project,https://github.com/OCA/project,github -repo_oca_project_agile,odoo_repository_org_oca,project-agile,https://github.com/OCA/project-agile,https://github.com/OCA/project-agile,github -repo_oca_project_reporting,odoo_repository_org_oca,project-reporting,https://github.com/OCA/project-reporting,https://github.com/OCA/project-reporting,github -repo_oca_purchase_reporting,odoo_repository_org_oca,purchase-reporting,https://github.com/OCA/purchase-reporting,https://github.com/OCA/purchase-reporting,github -repo_oca_purchase_workflow,odoo_repository_org_oca,purchase-workflow,https://github.com/OCA/purchase-workflow,https://github.com/OCA/purchase-workflow,github -repo_oca_pwa_builder,odoo_repository_org_oca,pwa-builder,https://github.com/OCA/pwa-builder,https://github.com/OCA/pwa-builder,github -repo_oca_queue,odoo_repository_org_oca,queue,https://github.com/OCA/queue,https://github.com/OCA/queue,github -repo_oca_repair,odoo_repository_org_oca,repair,https://github.com/OCA/repair,https://github.com/OCA/repair,github -repo_oca_report_print_send,odoo_repository_org_oca,report-print-send,https://github.com/OCA/report-print-send,https://github.com/OCA/report-print-send,github -repo_oca_reporting_engine,odoo_repository_org_oca,reporting-engine,https://github.com/OCA/reporting-engine,https://github.com/OCA/reporting-engine,github -repo_oca_resource,odoo_repository_org_oca,resource,https://github.com/OCA/resource,https://github.com/OCA/resource,github -repo_oca_rest_api,odoo_repository_org_oca,rest-api,https://github.com/OCA/rest-api,https://github.com/OCA/rest-api,github -repo_oca_rest_framework,odoo_repository_org_oca,rest-framework,https://github.com/OCA/rest-framework,https://github.com/OCA/rest-framework,github -repo_oca_rma,odoo_repository_org_oca,rma,https://github.com/OCA/rma,https://github.com/OCA/rma,github -repo_oca_role_policy,odoo_repository_org_oca,role-policy,https://github.com/OCA/role-policy,https://github.com/OCA/role-policy,github -repo_oca_sale_blanket,odoo_repository_org_oca,sale-blanket,https://github.com/OCA/sale-blanket,https://github.com/OCA/sale-blanket,github -repo_oca_sale_channel,odoo_repository_org_oca,sale-channel,https://github.com/OCA/sale-channel,https://github.com/OCA/sale-channel,github -repo_oca_sale_financial,odoo_repository_org_oca,sale-financial,https://github.com/OCA/sale-financial,https://github.com/OCA/sale-financial,github -repo_oca_sale_promotion,odoo_repository_org_oca,sale-promotion,https://github.com/OCA/sale-promotion,https://github.com/OCA/sale-promotion,github -repo_oca_sale_reporting,odoo_repository_org_oca,sale-reporting,https://github.com/OCA/sale-reporting,https://github.com/OCA/sale-reporting,github -repo_oca_sale_workflow,odoo_repository_org_oca,sale-workflow,https://github.com/OCA/sale-workflow,https://github.com/OCA/sale-workflow,github -repo_oca_search_engine,odoo_repository_org_oca,search-engine,https://github.com/OCA/search-engine,https://github.com/OCA/search-engine,github -repo_oca_server_auth,odoo_repository_org_oca,server-auth,https://github.com/OCA/server-auth,https://github.com/OCA/server-auth,github -repo_oca_server_backend,odoo_repository_org_oca,server-backend,https://github.com/OCA/server-backend,https://github.com/OCA/server-backend,github -repo_oca_server_brand,odoo_repository_org_oca,server-brand,https://github.com/OCA/server-brand,https://github.com/OCA/server-brand,github -repo_oca_server_env,odoo_repository_org_oca,server-env,https://github.com/OCA/server-env,https://github.com/OCA/server-env,github -repo_oca_server_tools,odoo_repository_org_oca,server-tools,https://github.com/OCA/server-tools,https://github.com/OCA/server-tools,github -repo_oca_server_ux,odoo_repository_org_oca,server-ux,https://github.com/OCA/server-ux,https://github.com/OCA/server-ux,github -repo_oca_shift_planning,odoo_repository_org_oca,shift-planning,https://github.com/OCA/shift-planning,https://github.com/OCA/shift-planning,github -repo_oca_shopfloor_app,odoo_repository_org_oca,shopfloor-app,https://github.com/OCA/shopfloor-app,https://github.com/OCA/shopfloor-app -repo_oca_sign,odoo_repository_org_oca,sign,https://github.com/OCA/sign,https://github.com/OCA/sign,github -repo_oca_social,odoo_repository_org_oca,social,https://github.com/OCA/social,https://github.com/OCA/social,github -repo_oca_spreadsheet,odoo_repository_org_oca,spreadsheet,https://github.com/OCA/spreadsheet,https://github.com/OCA/spreadsheet,github -repo_oca_stock_logistics_availability,odoo_repository_org_oca,stock-logistics-availability,https://github.com/OCA/stock-logistics-availability,https://github.com/OCA/stock-logistics-availability,github -repo_oca_stock_logistics_barcode,odoo_repository_org_oca,stock-logistics-barcode,https://github.com/OCA/stock-logistics-barcode,https://github.com/OCA/stock-logistics-barcode,github -repo_oca_stock_logistics_interfaces,odoo_repository_org_oca,stock-logistics-interfaces,https://github.com/OCA/stock-logistics-interfaces,https://github.com/OCA/stock-logistics-interfaces,github -repo_oca_stock_logistics_putaway,odoo_repository_org_oca,stock-logistics-putaway,https://github.com/OCA/stock-logistics-putaway,https://github.com/OCA/stock-logistics-putaway,github -repo_oca_stock_logistics_orderpoint,odoo_repository_org_oca,stock-logistics-orderpoint,https://github.com/OCA/stock-logistics-orderpoint,https://github.com/OCA/stock-logistics-orderpoint,github -repo_oca_stock_logistics_release_channel,odoo_repository_org_oca,stock-logistics-release-channel,https://github.com/OCA/stock-logistics-release-channel,https://github.com/OCA/stock-logistics-release-channel,github -repo_oca_stock_logistics_reporting,odoo_repository_org_oca,stock-logistics-reporting,https://github.com/OCA/stock-logistics-reporting,https://github.com/OCA/stock-logistics-reporting,github -repo_oca_stock_logistics_request,odoo_repository_org_oca,stock-logistics-request,https://github.com/OCA/stock-logistics-request,https://github.com/OCA/stock-logistics-request,github -repo_oca_stock_logistics_reservation,odoo_repository_org_oca,stock-logistics-reservation,https://github.com/OCA/stock-logistics-reservation,https://github.com/OCA/stock-logistics-reservation,github -repo_oca_stock_logistics_shopfloor,odoo_repository_org_oca,stock-logistics-shopfloor,https://github.com/OCA/stock-logistics-shopfloor,https://github.com/OCA/stock-logistics-shopfloor,github -repo_oca_stock_logistics_tracking,odoo_repository_org_oca,stock-logistics-tracking,https://github.com/OCA/stock-logistics-tracking,https://github.com/OCA/stock-logistics-tracking,github -repo_oca_stock_logistics_transport,odoo_repository_org_oca,stock-logistics-transport,https://github.com/OCA/stock-logistics-transport,https://github.com/OCA/stock-logistics-transport,github -repo_oca_stock_logistics_warehouse,odoo_repository_org_oca,stock-logistics-warehouse,https://github.com/OCA/stock-logistics-warehouse,https://github.com/OCA/stock-logistics-warehouse,github -repo_oca_stock_logistics_workflow,odoo_repository_org_oca,stock-logistics-workflow,https://github.com/OCA/stock-logistics-workflow,https://github.com/OCA/stock-logistics-workflow,github -repo_oca_stock_weighing,odoo_repository_org_oca,stock-weighing,https://github.com/OCA/stock-weighing,https://github.com/OCA/stock-weighing,github -repo_oca_storage,odoo_repository_org_oca,storage,https://github.com/OCA/storage,https://github.com/OCA/storage,github -repo_oca_survey,odoo_repository_org_oca,survey,https://github.com/OCA/survey,https://github.com/OCA/survey,github -repo_oca_timesheet,odoo_repository_org_oca,timesheet,https://github.com/OCA/timesheet,https://github.com/OCA/timesheet,github -repo_oca_vertical_abbey,odoo_repository_org_oca,vertical-abbey,https://github.com/OCA/vertical-abbey,https://github.com/OCA/vertical-abbey,github -repo_oca_vertical_agriculture,odoo_repository_org_oca,vertical-agriculture,https://github.com/OCA/vertical-agriculture,https://github.com/OCA/vertical-agriculture,github -repo_oca_vertical_association,odoo_repository_org_oca,vertical-association,https://github.com/OCA/vertical-association,https://github.com/OCA/vertical-association,github -repo_oca_vertical_community,odoo_repository_org_oca,vertical-community,https://github.com/OCA/vertical-community,https://github.com/OCA/vertical-community,github -repo_oca_vertical_construction,odoo_repository_org_oca,vertical-construction,https://github.com/OCA/vertical-construction,https://github.com/OCA/vertical-construction,github -repo_oca_vertical_cooperative_supermarket,odoo_repository_org_oca,vertical-cooperative-supermarket,https://github.com/OCA/vertical-cooperative-supermarket,https://github.com/OCA/vertical-cooperative-supermarket,github -repo_oca_vertical_edition,odoo_repository_org_oca,vertical-edition,https://github.com/OCA/vertical-edition,https://github.com/OCA/vertical-edition,github -repo_oca_vertical_education,odoo_repository_org_oca,vertical-education,https://github.com/OCA/vertical-education,https://github.com/OCA/vertical-education,github -repo_oca_vertical_hotel,odoo_repository_org_oca,vertical-hotel,https://github.com/OCA/vertical-hotel,https://github.com/OCA/vertical-hotel,github -repo_oca_vertical_isp,odoo_repository_org_oca,vertical-isp,https://github.com/OCA/vertical-isp,https://github.com/OCA/vertical-isp,github -repo_oca_vertical_medical,odoo_repository_org_oca,vertical-medical,https://github.com/OCA/vertical-medical,https://github.com/OCA/vertical-medical,github -repo_oca_vertical_ngo,odoo_repository_org_oca,vertical-ngo,https://github.com/OCA/vertical-ngo,https://github.com/OCA/vertical-ngo,github -repo_oca_vertical_realestate,odoo_repository_org_oca,vertical-realestate,https://github.com/OCA/vertical-realestate,https://github.com/OCA/vertical-realestate,github -repo_oca_vertical_rental,odoo_repository_org_oca,vertical-rental,https://github.com/OCA/vertical-rental,https://github.com/OCA/vertical-rental,github -repo_oca_vertical_travel,odoo_repository_org_oca,vertical-travel,https://github.com/OCA/vertical-travel,https://github.com/OCA/vertical-travel,github -repo_oca_wallet,odoo_repository_org_oca,wallet,https://github.com/OCA/wallet,https://github.com/OCA/wallet,github -repo_oca_web,odoo_repository_org_oca,web,https://github.com/OCA/web,https://github.com/OCA/web,github -repo_oca_web_api,odoo_repository_org_oca,web-api,https://github.com/OCA/web-api,https://github.com/OCA/web-api,github -repo_oca_web_api_contrib,odoo_repository_org_oca,web-api-contrib,https://github.com/OCA/web-api-contrib,https://github.com/OCA/web-api-contrib,github -repo_oca_webhook,odoo_repository_org_oca,webhook,https://github.com/OCA/webhook,https://github.com/OCA/webhook,github -repo_oca_webkit_tools,odoo_repository_org_oca,webkit-tools,https://github.com/OCA/webkit-tools,https://github.com/OCA/webkit-tools,github -repo_oca_website,odoo_repository_org_oca,website,https://github.com/OCA/website,https://github.com/OCA/website,github -repo_oca_website_cms,odoo_repository_org_oca,website-cms,https://github.com/OCA/website-cms,https://github.com/OCA/website-cms,github -repo_oca_website_themes,odoo_repository_org_oca,website-themes,https://github.com/OCA/website-themes,https://github.com/OCA/website-themes,github -repo_oca_wms,odoo_repository_org_oca,wms,https://github.com/OCA/wms,https://github.com/OCA/wms,github diff --git a/odoo_repository/data/odoo_mca_backend.xml b/odoo_repository/data/odoo_mca_backend.xml new file mode 100644 index 00000000..ee36ea77 --- /dev/null +++ b/odoo_repository/data/odoo_mca_backend.xml @@ -0,0 +1,8 @@ + + + + + MCA + + diff --git a/odoo_repository/data/queue_job.xml b/odoo_repository/data/queue_job.xml index f38e67a9..99e5c7ba 100644 --- a/odoo_repository/data/queue_job.xml +++ b/odoo_repository/data/queue_job.xml @@ -1,5 +1,6 @@ @@ -51,4 +52,14 @@ + + + + _sync_oca_repositories_job + + + diff --git a/odoo_repository/migrations/18.0.1.1.0/pre-migration.py b/odoo_repository/migrations/18.0.1.1.0/pre-migration.py new file mode 100644 index 00000000..ac69c5be --- /dev/null +++ b/odoo_repository/migrations/18.0.1.1.0/pre-migration.py @@ -0,0 +1,48 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + remove_xmlids_from_oca_repositories(cr) + + +def remove_xmlids_from_oca_repositories(cr): + """Remove XML-IDs from OCA repositories to prevent deletion during upgrade.""" + _logger.info("Removing XML-IDs from OCA repositories...") + # First, get the OCA organization ID + cr.execute("SELECT id FROM odoo_repository_org WHERE name = 'OCA'") + oca_org_result = cr.fetchone() + if not oca_org_result: + _logger.warning("OCA organization not found in database") + return + oca_org_id = oca_org_result[0] + # Get all OCA repositories that have XML-IDs + cr.execute( + """ + SELECT imd.id, imd.res_id + FROM ir_model_data imd + JOIN odoo_repository repo ON imd.res_id = repo.id + WHERE imd.model = 'odoo.repository' + AND repo.org_id = %s + """, + (oca_org_id,), + ) + xmlid_records = cr.fetchall() + if not xmlid_records: + _logger.info("No OCA repositories with XML-IDs found") + return + # Delete the XML-IDs from ir.model.data + xmlid_ids = [record[0] for record in xmlid_records] + repository_ids = [record[1] for record in xmlid_records] + cr.execute("DELETE FROM ir_model_data WHERE id = ANY(%s)", (xmlid_ids,)) + _logger.info( + "Removed %s XML-IDs from OCA repositories (repository IDs: %s)", + len(xmlid_ids), + repository_ids, + ) diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py index e32af68e..6c8e744d 100644 --- a/odoo_repository/models/__init__.py +++ b/odoo_repository/models/__init__.py @@ -10,6 +10,7 @@ from . import odoo_python_dependency from . import odoo_repository_org from . import odoo_repository_addons_path +from . import odoo_mca_backend from . import odoo_repository from . import odoo_repository_branch from . import odoo_module_branch diff --git a/odoo_repository/models/odoo_mca_backend.py b/odoo_repository/models/odoo_mca_backend.py new file mode 100644 index 00000000..166a3e4f --- /dev/null +++ b/odoo_repository/models/odoo_mca_backend.py @@ -0,0 +1,17 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class OdooMCABackend(models.Model): + """Backend collection for Odoo MCA system. + + This backend is used as the collection for all MCA-related components. + """ + + _name = "odoo.mca.backend" + _inherit = "collection.base" + _description = "Odoo MCA Backend" + + name = fields.Char(required=True) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index f0d0ec38..71bbeba1 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -1,4 +1,5 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json @@ -669,6 +670,36 @@ def unlink(self): rec.branch_ids.module_ids.sudo().unlink() return super().unlink() + @api.model + def cron_sync_oca_repositories(self): + """Create and update OCA repositories from GitHub configuration. + + This method spawns a job to fetch the repository configurations from + OCA/repo-maintainer-conf, parse the YAML files, and synchronize the local + repository database by: + - Creating new repositories that exist in OCA config but not locally + - Updating existing repositories with any changes + - Archiving repositories that no longer exist in OCA config + """ + delayable = self.delayable( + identity_key=identity_exact, + priority=1, + ) + delayable._sync_oca_repositories_job() + delayable.delay() + return True + + @api.model + def _sync_oca_repositories_job(self): + """Queue job method to synchronize OCA repositories. + + Performs the actual synchronization of OCA repositories from GitHub. + """ + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + return synchronizer.run() + def open_modules(self): self.ensure_one() xml_id = "odoo_repository.odoo_module_branch_action" diff --git a/odoo_repository/models/res_config_settings.py b/odoo_repository/models/res_config_settings.py index ef16923d..c6d6d501 100644 --- a/odoo_repository/models/res_config_settings.py +++ b/odoo_repository/models/res_config_settings.py @@ -1,4 +1,5 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import fields, models @@ -21,3 +22,11 @@ class ResConfigSettings(models.TransientModel): config_odoo_repository_main_node_url = fields.Char( string="Main Node", config_parameter="odoo_repository_main_node_url" ) + config_odoo_repository_oca_blacklist = fields.Char( + string="OCA Repository Blacklist", + config_parameter="odoo_repository.oca_repo_blacklist", + help=( + "Comma-separated list of OCA repositories to skip during synchronization. " + "Example: OCB,OpenUpgrade" + ), + ) diff --git a/odoo_repository/readme/DESCRIPTION.md b/odoo_repository/readme/DESCRIPTION.md index 2fea33b6..fc891aeb 100644 --- a/odoo_repository/readme/DESCRIPTION.md +++ b/odoo_repository/readme/DESCRIPTION.md @@ -2,5 +2,5 @@ Base module to host data collected from Odoo repositories. It allows you to: - declare the Odoo versions (last 3 versions by default) -- declare repositories containing modules (Odoo and OCA repositories included by default) +- declare repositories containing modules (Odoo repositories included by default, and OCA ones are synced automatically) - scan these repositories to collect modules informations per Odoo version diff --git a/odoo_repository/security/ir.model.access.csv b/odoo_repository/security/ir.model.access.csv index db75b492..b3b9972a 100644 --- a/odoo_repository/security/ir.model.access.csv +++ b/odoo_repository/security/ir.model.access.csv @@ -25,3 +25,4 @@ access_odoo_module_branch_user,odoo_module_branch_user,model_odoo_module_branch, access_odoo_module_branch_manager,odoo_module_branch_manager,model_odoo_module_branch,group_odoo_repository_manager,1,1,0,0 access_odoo_module_branch_version_user,odoo_module_branch_version_user,model_odoo_module_branch_version,group_odoo_repository_user,1,0,0,0 access_odoo_module_branch_version_manager,odoo_module_branch_version_manager,model_odoo_module_branch_version,group_odoo_repository_manager,1,1,1,1 +access_odoo_mca_backend_user,odoo_mca_backend_user,model_odoo_mca_backend,group_odoo_repository_user,1,0,0,0 diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index a8067562..8f3f0b6d 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_odoo_repository_scan from . import test_sync_node from . import test_odoo_module_branch +from . import test_oca_repository_synchronizer diff --git a/odoo_repository/tests/test_oca_repository_synchronizer.py b/odoo_repository/tests/test_oca_repository_synchronizer.py new file mode 100644 index 00000000..f668bd65 --- /dev/null +++ b/odoo_repository/tests/test_oca_repository_synchronizer.py @@ -0,0 +1,280 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from unittest.mock import MagicMock, patch + +from odoo import tools + +from odoo.addons.component.tests.common import TransactionComponentCase +from odoo.addons.queue_job.exception import RetryableJobError + + +class TestOCARepositorySynchronizer(TransactionComponentCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + cls.repository_model = cls.env["odoo.repository"] + cls.oca_org = cls.env.ref("odoo_repository.odoo_repository_org_oca") + # Create a test repository that should be archived + cls.test_repo = cls.repository_model.create( + { + "org_id": cls.oca_org.id, + "name": "test-archive-repo", + "repo_url": "https://github.com/OCA/test-archive-repo", + "clone_url": "https://github.com/OCA/test-archive-repo", + "repo_type": "github", + "active": True, + } + ) + + def _mock_requests_get(self, mock_get): + # Mock GitHub API response for directory listing + mock_dir_response = MagicMock() + mock_dir_response.json.return_value = [ + { + "name": "test.yml", + "download_url": ( + "https://raw.githubusercontent.com/" + "OCA/repo-maintainer-conf/master/conf/repo/test.yml" + ), + } + ] + mock_dir_response.raise_for_status.return_value = None + # Mock YAML file content + mock_yaml_response = MagicMock() + mock_yaml_response.text = """ + test-repo: + branches: + - "16.0" + name: Test + """ + mock_yaml_response.raise_for_status.return_value = None + + # Setup mock to return different responses based on URL + def get_side_effect(url, **kwargs): + if "contents/conf/repo" in url: + return mock_dir_response + else: + return mock_yaml_response + + mock_get.side_effect = get_side_effect + + def test_parse_oca_repo_configurations(self): + """Test OCA repository configurations parsing. + + Only those with branches are returned. + """ + yaml_content = """ + test-repo-no-branches: + branches: [] + name: Test Repo + test-repo-with-branches: + branches: + - "16.0" + - "17.0" + name: Test Repo With Branches + """ + repo_configs = {"test.yml": yaml_content} + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + result = synchronizer._parse_oca_repo_configurations(repo_configs) + # Should only include repo with branches + self.assertEqual(len(result), 1) + self.assertNotIn("test-repo-no-branches", result) + self.assertIn("test-repo-with-branches", result) + repo_data = result["test-repo-with-branches"] + self.assertEqual(repo_data["name"], "test-repo-with-branches") + self.assertEqual( + repo_data["repo_url"], "https://github.com/OCA/test-repo-with-branches" + ) + + def test_parse_oca_repo_configurations_default_branch(self): + """Test OCA repository configurations parsing with default_branch filtering. + + Repositories with default_branch='master' or 'main' should be skipped. + """ + yaml_content = """ + repo-master: + default_branch: master + branches: + - "16.0" + name: Master Branch Repo + repo-main: + default_branch: main + branches: + - "16.0" + name: Main Branch Repo + repo-odoo-branch: + default_branch: 16.0 + branches: + - "16.0" + - "17.0" + name: Odoo Branch Repo + repo-no-default: + branches: + - "16.0" + name: No Default Branch Repo + """ + repo_configs = {"test.yml": yaml_content} + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + result = synchronizer._parse_oca_repo_configurations(repo_configs) + # Should only include repos with non-master/main default_branch + # or no default_branch + self.assertEqual(len(result), 2) + self.assertNotIn("repo-master", result) + self.assertNotIn("repo-main", result) + self.assertIn("repo-odoo-branch", result) + self.assertIn("repo-no-default", result) + + def test_sync_oca_repositories_archive(self): + """Test that repositories not in current config are archived.""" + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + # Mock current repositories (empty = no repos in config) + current_repos = {} + stats = synchronizer._sync_oca_repositories(current_repos) + # Check that our test repo was archived + self.assertEqual(stats["archived"], 1) + self.test_repo.invalidate_model() + self.assertFalse(self.test_repo.active) + self.assertFalse(self.test_repo.to_scan) + + def test_sync_oca_repositories_create(self): + """Test creation of new repositories from config.""" + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + current_repos = { + "new-test-repo": { + "name": "new-test-repo", + "repo_url": "https://github.com/OCA/new-test-repo", + "clone_url": "https://github.com/OCA/new-test-repo", + "repo_type": "github", + } + } + stats = synchronizer._sync_oca_repositories(current_repos) + self.assertEqual(stats["created"], 1) + # Check that repository was created + new_repo = self.repository_model.search([("name", "=", "new-test-repo")]) + self.assertTrue(new_repo.exists()) + self.assertEqual(new_repo.org_id, self.oca_org) + self.assertEqual(new_repo.repo_url, "https://github.com/OCA/new-test-repo") + self.assertTrue(new_repo.active) + self.assertTrue(new_repo.to_scan) + + def test_sync_oca_repositories_update(self): + """Test updating existing repositories.""" + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + current_repos = { + "test-archive-repo": { + "name": "test-archive-repo", + "repo_url": "https://github.com/OCA/test-archive-repo-updated", + "clone_url": "https://github.com/OCA/test-archive-repo-updated", + "repo_type": "github", + } + } + stats = synchronizer._sync_oca_repositories(current_repos) + self.assertEqual(stats["updated"], 1) + # Check that repository was updated + self.test_repo.invalidate_model() + self.assertEqual( + self.test_repo.repo_url, "https://github.com/OCA/test-archive-repo-updated" + ) + self.assertTrue(self.test_repo.active) + + @patch("requests.get") + def test_fetch_oca_repo_configurations(self, mock_get): + """Test fetching repository configuration from GitHub.""" + self._mock_requests_get(mock_get) + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + result = synchronizer._fetch_oca_repo_configurations() + self.assertIn("test.yml", result) + self.assertIn("test-repo:", result["test.yml"]) + + @patch("requests.get") + def test_run_oca_repository_synchronizer(self, mock_get): + """Test running the repository synchronizer component (main entrypoint).""" + self._mock_requests_get(mock_get) + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + stats = synchronizer.run() + self.assertEqual(stats["created"], 1) + self.assertEqual(stats["updated"], 0) + + def test_run_oca_repository_synchronizer_error(self): + """Test running the repository synchronizer component raising exception.""" + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + synchronizer.github_conf_api_url = "wrong_api_url" + with self.assertRaises(RetryableJobError): + with tools.mute_logger( + "odoo.addons.odoo_repository.components.oca_repository_synchronizer" + ): + synchronizer.run() + + @patch("requests.get") + def test_cron_sync_oca_repositories(self, mock_get): + """Test cron method syncing OCA repositories from GitHub.""" + self._mock_requests_get(mock_get) + result = self.repository_model.cron_sync_oca_repositories() + self.assertTrue(result) + test_repo = self.repository_model.search([("name", "=", "test-repo")]) + self.assertEqual(test_repo.name, "test-repo") + self.assertEqual( + test_repo.repo_url, test_repo.clone_url, "https://github.com/OCA/test-repo" + ) + + def test_oca_repo_blacklist_configurable(self): + """Test that OCA repository blacklist is configurable through settings.""" + yaml_content = """ + blacklisted-repo: + branches: + - "16.0" + name: Blacklisted Repo + normal-repo: + branches: + - "16.0" + name: Normal Repo + """ + repo_configs = {"test.yml": yaml_content} + # Test with default blacklist (should skip OCB and OpenUpgrade) + mca_backend = self.env.ref("odoo_repository.mca_backend") + with mca_backend.work_on("odoo.repository") as work: + synchronizer = work.component(usage="oca.repository.synchronizer") + # Test default blacklist + default_blacklist = synchronizer._get_oca_repo_blacklist() + self.assertIn("OCB", default_blacklist) + self.assertIn("OpenUpgrade", default_blacklist) + # Test parsing with custom blacklist (comma-separated) + self.env["ir.config_parameter"].sudo().set_param( + "odoo_repository.oca_repo_blacklist", "OCB,OpenUpgrade,blacklisted-repo" + ) + custom_blacklist = synchronizer._get_oca_repo_blacklist() + self.assertIn("OCB", custom_blacklist) + self.assertIn("OpenUpgrade", custom_blacklist) + self.assertIn("blacklisted-repo", custom_blacklist) + # Test that blacklisted repo is skipped + result = synchronizer._parse_oca_repo_configurations(repo_configs) + self.assertNotIn("blacklisted-repo", result) + self.assertIn("normal-repo", result) + # Test with empty blacklist + self.env["ir.config_parameter"].sudo().set_param( + "odoo_repository.oca_repo_blacklist", "" + ) + empty_blacklist = synchronizer._get_oca_repo_blacklist() + self.assertEqual(empty_blacklist, []) + # Test that both repos are included when blacklist is empty + result = synchronizer._parse_oca_repo_configurations(repo_configs) + self.assertIn("blacklisted-repo", result) + self.assertIn("normal-repo", result) diff --git a/odoo_repository/tests/test_odoo_module_branch.py b/odoo_repository/tests/test_odoo_module_branch.py index c0424d72..096b027a 100644 --- a/odoo_repository/tests/test_odoo_module_branch.py +++ b/odoo_repository/tests/test_odoo_module_branch.py @@ -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.exceptions import ValidationError diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 79d43ada..3367ccc7 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -1,5 +1,6 @@ @@ -52,6 +53,15 @@ placeholder="https://example.net/odoo-repository/data" /> + + + From ac01c3716001e7dd090b9bf5af2b1c51d1ee8256 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Sun, 22 Feb 2026 16:07:39 +0000 Subject: [PATCH 139/160] [UPD] Update odoo_repository.pot --- odoo_repository/i18n/odoo_repository.pot | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/odoo_repository/i18n/odoo_repository.pot b/odoo_repository/i18n/odoo_repository.pot index cdf2494a..fa9dc088 100644 --- a/odoo_repository/i18n/odoo_repository.pot +++ b/odoo_repository/i18n/odoo_repository.pot @@ -189,6 +189,19 @@ msgstr "" msgid "Code analysis" msgstr "" +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "" +"Comma-separated list of OCA repositories to skip during synchronization." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_oca_blacklist +msgid "" +"Comma-separated list of OCA repositories to skip during synchronization. " +"Example: OCB,OpenUpgrade" +msgstr "" + #. module: odoo_repository #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__commit msgid "Commit" @@ -236,6 +249,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_uid @@ -256,6 +270,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_date @@ -329,6 +344,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__display_name @@ -481,6 +497,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__id @@ -546,6 +563,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_uid @@ -566,6 +584,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_date @@ -689,6 +708,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__name @@ -711,6 +731,16 @@ msgstr "" msgid "Non-Std Dep. Level" msgstr "" +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_oca_blacklist +msgid "OCA Repository Blacklist" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "OCB,OpenUpgrade" +msgstr "" + #. module: odoo_repository #: model:ir.model,name:odoo_repository.model_odoo_branch msgid "Odoo Branch/Version" @@ -738,6 +768,11 @@ msgstr "" msgid "Odoo MCA - Scanner" msgstr "" +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_mca_backend +msgid "Odoo MCA Backend" +msgstr "" + #. module: odoo_repository #: model:res.groups,name:odoo_repository.group_odoo_repository_manager msgid "Odoo MCA Manager" @@ -793,6 +828,11 @@ msgstr "" msgid "Odoo Python Dependency" msgstr "" +#. module: odoo_repository +#: model:ir.actions.server,name:odoo_repository.cron_sync_oca_repositories_ir_actions_server +msgid "Odoo Repositories - Sync OCA repositories" +msgstr "" + #. module: odoo_repository #: model:ir.model,name:odoo_repository.model_odoo_repository_org msgid "Odoo Repository Organization" From 96a717e9a73c5a6eac4388b750d7725df0d14ed8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 22 Feb 2026 16:12:23 +0000 Subject: [PATCH 140/160] [BOT] post-merge updates --- odoo_repository/README.rst | 6 +++--- odoo_repository/static/description/index.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 2fdf1145..206d793c 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:efea65fb08abe8c5a966ac83baa3b7e0dcdbce8530ff918bb5040861fda43fe8 + !! source digest: sha256:46c23a389642b4329f38a8d487deffffc045237c9a9df754ceede1cc310eaebf !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -37,8 +37,8 @@ Base module to host data collected from Odoo repositories. It allows you to: - declare the Odoo versions (last 3 versions by default) -- declare repositories containing modules (Odoo and OCA repositories - included by default) +- declare repositories containing modules (Odoo repositories included by + default, and OCA ones are synced automatically) - scan these repositories to collect modules informations per Odoo version diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 43882b2e..05edc438 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,15 +372,15 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:efea65fb08abe8c5a966ac83baa3b7e0dcdbce8530ff918bb5040861fda43fe8 +!! source digest: sha256:46c23a389642b4329f38a8d487deffffc045237c9a9df754ceede1cc310eaebf !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    It allows you to:

    • declare the Odoo versions (last 3 versions by default)
    • -
    • declare repositories containing modules (Odoo and OCA repositories -included by default)
    • +
    • declare repositories containing modules (Odoo repositories included by +default, and OCA ones are synced automatically)
    • scan these repositories to collect modules informations per Odoo version
    From 5463989ebf0d0b7f96044bb14b1be35c78bb78ac Mon Sep 17 00:00:00 2001 From: Weblate Date: Sun, 22 Feb 2026 16:12:31 +0000 Subject: [PATCH 141/160] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: module-composition-analysis-18.0/module-composition-analysis-18.0-odoo_repository Translate-URL: https://translation.odoo-community.org/projects/module-composition-analysis-18-0/module-composition-analysis-18-0-odoo_repository/ --- odoo_repository/i18n/it.po | 51 +++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/odoo_repository/i18n/it.po b/odoo_repository/i18n/it.po index d959fe74..61f9afe9 100644 --- a/odoo_repository/i18n/it.po +++ b/odoo_repository/i18n/it.po @@ -190,6 +190,19 @@ msgstr "" msgid "Code analysis" msgstr "" +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "" +"Comma-separated list of OCA repositories to skip during synchronization." +msgstr "" + +#. module: odoo_repository +#: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_oca_blacklist +msgid "" +"Comma-separated list of OCA repositories to skip during synchronization. " +"Example: OCB,OpenUpgrade" +msgstr "" + #. module: odoo_repository #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__commit msgid "Commit" @@ -237,6 +250,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_uid @@ -257,6 +271,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__create_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__create_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__create_date @@ -286,8 +301,7 @@ msgstr "" #: model:ir.model.fields,help:odoo_repository.field_res_company__config_odoo_repository_default_token_id #: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_default_token_id msgid "" -"Default token used to clone repositories and authenticate on API like " -"GitHub." +"Default token used to clone repositories and authenticate on API like GitHub." msgstr "" #. module: odoo_repository @@ -330,6 +344,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__display_name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__display_name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__display_name @@ -399,8 +414,8 @@ msgstr "" #: model:ir.model.fields,help:odoo_repository.field_res_config_settings__config_odoo_repository_workaround_fs_errors msgid "" "Fix file system permissions when cloning repositories. Errors could be " -"triggered on some file systems when git tries to execute 'chown' commands on" -" its internal configuration files. This option will workaround this issue." +"triggered on some file systems when git tries to execute 'chown' commands on " +"its internal configuration files. This option will workaround this issue." msgstr "" #. module: odoo_repository @@ -433,8 +448,8 @@ msgstr "" #. odoo-python #: code:addons/odoo_repository/models/odoo_module_branch.py:0 msgid "" -"Generic module %(generic_mod)s cannot depend on specific module(s): " -"%(specific_mods)s" +"Generic module %(generic_mod)s cannot depend on specific module(s): %" +"(specific_mods)s" msgstr "" #. module: odoo_repository @@ -482,6 +497,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__id +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__id #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__id @@ -547,6 +563,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_uid +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_uid #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_uid @@ -567,6 +584,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__write_date +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch__write_date #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__write_date @@ -690,6 +708,7 @@ msgstr "" #: model:ir.model.fields,field_description:odoo_repository.field_odoo_author__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_license__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_maintainer__name +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_mca_backend__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_branch_version__name #: model:ir.model.fields,field_description:odoo_repository.field_odoo_module_category__name @@ -712,6 +731,16 @@ msgstr "" msgid "Non-Std Dep. Level" msgstr "" +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_res_config_settings__config_odoo_repository_oca_blacklist +msgid "OCA Repository Blacklist" +msgstr "" + +#. module: odoo_repository +#: model_terms:ir.ui.view,arch_db:odoo_repository.res_config_settings_view_form +msgid "OCB,OpenUpgrade" +msgstr "" + #. module: odoo_repository #: model:ir.model,name:odoo_repository.model_odoo_branch msgid "Odoo Branch/Version" @@ -739,6 +768,11 @@ msgstr "" msgid "Odoo MCA - Scanner" msgstr "" +#. module: odoo_repository +#: model:ir.model,name:odoo_repository.model_odoo_mca_backend +msgid "Odoo MCA Backend" +msgstr "" + #. module: odoo_repository #: model:res.groups,name:odoo_repository.group_odoo_repository_manager msgid "Odoo MCA Manager" @@ -794,6 +828,11 @@ msgstr "" msgid "Odoo Python Dependency" msgstr "" +#. module: odoo_repository +#: model:ir.actions.server,name:odoo_repository.cron_sync_oca_repositories_ir_actions_server +msgid "Odoo Repositories - Sync OCA repositories" +msgstr "" + #. module: odoo_repository #: model:ir.model,name:odoo_repository.model_odoo_repository_org msgid "Odoo Repository Organization" From f4552fd65ce357473f1b97b229141e14f2bcd799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 22 Feb 2026 18:01:47 +0100 Subject: [PATCH 142/160] [IMP] odoo_repository: replace oca-port dependency in tests And cache generated git repository in memory to speed up tests execution. --- odoo_repository/tests/common.py | 20 +-- odoo_repository/tests/odoo_repo_mixin.py | 160 ++++++++++++++++++ odoo_repository/tests/test_base_scanner.py | 2 +- .../tests/test_odoo_repository_scan.py | 5 +- .../tests/test_repository_scanner.py | 2 +- 5 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 odoo_repository/tests/odoo_repo_mixin.py diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 9dfe8d25..c0cc1aca 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -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 logging @@ -10,18 +11,18 @@ import git import psutil -from oca_port.tests.common import CommonCase from odoo.tests.common import TransactionCase +from .odoo_repo_mixin import OdooRepoMixin + _logger = logging.getLogger(__name__) -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( @@ -32,9 +33,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( @@ -90,13 +88,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.""" diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py new file mode 100644 index 00000000..b9cd7bca --- /dev/null +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -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) diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index a3d2e515..515e408c 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -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, diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 9298124f..12c06ba0 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -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 @@ -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") @@ -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 diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index 7102b547..4943e529 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -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": [ From bfeb6708d32efe51cdf65eeedef75252a3e5585c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 24 Feb 2026 15:03:01 +0000 Subject: [PATCH 143/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 206d793c..f54ffcaf 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:46c23a389642b4329f38a8d487deffffc045237c9a9df754ceede1cc310eaebf + !! source digest: sha256:328550e69430ad717a18f199a15539f4494c9c1ff458e970b1a5bf0870a28d00 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 6998f8e6..c966ae0e 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.0", + "version": "18.0.1.1.1", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 05edc438..57041a25 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:46c23a389642b4329f38a8d487deffffc045237c9a9df754ceede1cc310eaebf +!! source digest: sha256:328550e69430ad717a18f199a15539f4494c9c1ff458e970b1a5bf0870a28d00 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From 7b658c134e743ca8bc91736e1434a62ae815de46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 26 Feb 2026 08:11:15 +0100 Subject: [PATCH 144/160] [IMP] odoo_repository: generate git repository in setUpClass --- odoo_repository/tests/common.py | 65 ++++++++---------- odoo_repository/tests/odoo_repo_mixin.py | 87 ++++++++++++++---------- 2 files changed, 80 insertions(+), 72 deletions(-) diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index c0cc1aca..0564a848 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -3,7 +3,6 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import logging -import os import pathlib import re import tempfile @@ -28,36 +27,36 @@ def setUpClass(cls): cls.env["ir.config_parameter"].set_param( "odoo_repository_storage_path", cls.repositories_path ) - cls._apply_git_config() - cls._handle_cleanup() - - 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 + cls._handle_cleanup() + + def setUp(self): + super().setUp() # branch2 self.branch2_name = self.source2.split("/")[1] self.branch2 = ( @@ -78,15 +77,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") @@ -145,24 +135,27 @@ 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) @classmethod def _handle_cleanup(cls): diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py index b9cd7bca..187d51cc 100644 --- a/odoo_repository/tests/odoo_repo_mixin.py +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import io -import shutil +import os import tempfile import threading import time @@ -29,34 +29,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. @@ -70,7 +79,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. @@ -83,28 +93,31 @@ 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 @@ -112,21 +125,22 @@ def _fill_git_repository(self, repo_path: Path, addon_path: Path): 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", @@ -148,13 +162,14 @@ 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() - # Clean up the Git repository - shutil.rmtree(self.repo_upstream_path) - shutil.rmtree(self.repo_path) + # def tearDown(self): + # super().tearDown() + # # Clean up the Git repository + # shutil.rmtree(self.repo_upstream_path) + # shutil.rmtree(self.repo_path) From c7cdb1810faba059257656799d818cd039086b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 24 Feb 2026 19:04:18 +0100 Subject: [PATCH 145/160] [FIX] odoo_repository: fix '_get_recursive_dependencies' method --- odoo_repository/models/odoo_module_branch.py | 15 +- odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/odoo_repo_mixin.py | 12 +- ...oo_module_branch_recursive_dependencies.py | 203 ++++++++++++++++++ 4 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 odoo_repository/tests/test_odoo_module_branch_recursive_dependencies.py diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index ba39bffa..2229d0f4 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -294,7 +294,7 @@ def _compute_dependency_level(self): (non_std_max_parent_level + 1) if not rec.is_standard 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: @@ -302,13 +302,22 @@ def _get_recursive_dependencies(self, domain=None): >>> 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) diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index 8f3f0b6d..ee060d5d 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -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 diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py index 187d51cc..40cee150 100644 --- a/odoo_repository/tests/odoo_repo_mixin.py +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -3,6 +3,7 @@ import io import os +import shutil import tempfile import threading import time @@ -168,8 +169,9 @@ def _add_fork_remote(cls, repo_path: Path): # We do not really care about the remote URL here, re-use origin one repo.create_remote(cls.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) + @classmethod + def tearDownClass(cls): + super().tearDownClass() + # Clean up the Git repository + shutil.rmtree(cls.repo_upstream_path) + shutil.rmtree(cls.repo_path) diff --git a/odoo_repository/tests/test_odoo_module_branch_recursive_dependencies.py b/odoo_repository/tests/test_odoo_module_branch_recursive_dependencies.py new file mode 100644 index 00000000..f6b30940 --- /dev/null +++ b/odoo_repository/tests/test_odoo_module_branch_recursive_dependencies.py @@ -0,0 +1,203 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestOdooModuleBranchRecursiveDependencies(Common): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create modules + cls.mod_base = cls._create_odoo_module("base") + cls.mod_a = cls._create_odoo_module("module_a") + cls.mod_b = cls._create_odoo_module("module_b") + cls.mod_c = cls._create_odoo_module("module_c") + cls.mod_d = cls._create_odoo_module("module_d") + cls.mod_e = cls._create_odoo_module("module_e") + # Create module branches + cls.mod_base_branch = cls._create_odoo_module_branch( + cls.mod_base, cls.branch, is_standard=True + ) + cls.mod_a_branch = cls._create_odoo_module_branch(cls.mod_a, cls.branch) + cls.mod_b_branch = cls._create_odoo_module_branch(cls.mod_b, cls.branch) + cls.mod_c_branch = cls._create_odoo_module_branch(cls.mod_c, cls.branch) + cls.mod_d_branch = cls._create_odoo_module_branch(cls.mod_d, cls.branch) + cls.mod_e_branch = cls._create_odoo_module_branch(cls.mod_e, cls.branch) + + def test_get_recursive_dependencies_simple(self): + """Test _get_recursive_dependencies with a simple dependency chain.""" + self.mod_a_branch.dependency_ids = self.mod_base_branch + self.mod_b_branch.dependency_ids = self.mod_a_branch + self.mod_c_branch.dependency_ids = self.mod_b_branch + # Test recursive dependencies for module_c + deps = self.mod_c_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps) + self.assertIn(self.mod_a_branch, deps) + self.assertIn(self.mod_b_branch, deps) + self.assertEqual(len(deps), 3) # base, module_a, module_b + + def test_get_recursive_dependencies_with_domain(self): + """Test _get_recursive_dependencies with domain filtering.""" + # Create organization + org_oca = self.env.ref("odoo_repository.odoo_repository_org_oca") + org_other = self.env["odoo.repository.org"].create({"name": "Other"}) + # Create repositories + repo_oca = self.env["odoo.repository"].create( + { + "org_id": org_oca.id, + "name": "repo_oca", + "repo_url": "https://github.com/OCA/repo_oca", + "clone_url": "https://github.com/OCA/repo_oca", + "repo_type": "github", + } + ) + repo_other = self.env["odoo.repository"].create( + { + "org_id": org_other.id, + "name": "repo_other", + "repo_url": "https://github.com/Other/repo_other", + "clone_url": "https://github.com/Other/repo_other", + "repo_type": "github", + } + ) + # Create modules + mod_oca = self._create_odoo_module("module_oca") + mod_other = self._create_odoo_module("module_other") + mod_mixed = self._create_odoo_module("module_mixed") + # Create repository branches + repo_oca_branch = self._create_odoo_repository_branch(repo_oca, self.branch) + repo_other_branch = self._create_odoo_repository_branch(repo_other, self.branch) + # Create module branches + mod_oca_branch = self._create_odoo_module_branch( + mod_oca, + self.branch, + repository_branch_id=repo_oca_branch.id, + dependency_ids=[(4, self.mod_base_branch.id)], + ) + mod_other_branch = self._create_odoo_module_branch( + mod_other, + self.branch, + repository_branch_id=repo_other_branch.id, + dependency_ids=[(4, self.mod_base_branch.id)], + ) + mod_mixed_branch = self._create_odoo_module_branch( + mod_mixed, + self.branch, + repository_branch_id=repo_oca_branch.id, + dependency_ids=[(4, mod_oca_branch.id), (4, mod_other_branch.id)], + ) + # Test recursive dependencies with OCA filter + deps = mod_mixed_branch._get_recursive_dependencies( + [("org_id", "=", org_oca.id)] + ) + self.assertIn(mod_oca_branch, deps) + self.assertNotIn(mod_other_branch, deps) # filtered out + # base has no org, so it's filtered out by the domain + self.assertNotIn(self.mod_base_branch, deps) + self.assertEqual(len(deps), 1) # only module_oca + + def test_get_recursive_dependencies_circular_1(self): + """Test _get_recursive_dependencies handles circular dependencies (1).""" + # Create module branches with circular dependencies + # base + # ├── a ─┐ + # ├── b │ + # ├── c │ + # └── d <┘ + self.mod_a_branch.dependency_ids = self.mod_base_branch + self.mod_b_branch.dependency_ids = self.mod_a_branch + self.mod_c_branch.dependency_ids = self.mod_b_branch + self.mod_d_branch.dependency_ids = self.mod_c_branch + # Create circular dependency: mod_a -> mod_d + self.mod_a_branch.dependency_ids |= self.mod_d_branch + # Test recursive dependencies - should not infinite loop + deps = self.mod_b_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps) + self.assertIn(self.mod_a_branch, deps) + self.assertIn(self.mod_b_branch, deps) + self.assertIn(self.mod_c_branch, deps) + self.assertIn(self.mod_d_branch, deps) + # Check that we get the expected dependencies + # We expect base, a, b, c, d (mod_b is part from its own dependencies) + self.assertEqual(len(deps), 5) + + def test_get_recursive_dependencies_circular_2(self): + """Test _get_recursive_dependencies handles circular dependencies (2).""" + # Create module branches with circular dependencies + # base + # ├── a ─┐ + # ├── b │ + # ├── c <┘ + # └── d + self.mod_a_branch.dependency_ids = self.mod_base_branch + self.mod_b_branch.dependency_ids = self.mod_a_branch + self.mod_c_branch.dependency_ids = self.mod_b_branch + self.mod_d_branch.dependency_ids = self.mod_c_branch + # Create circular dependency: mod_a -> mod_d + self.mod_a_branch.dependency_ids |= self.mod_c_branch + # Test recursive dependencies - should not infinite loop + deps = self.mod_d_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps) + self.assertIn(self.mod_a_branch, deps) + self.assertIn(self.mod_b_branch, deps) + self.assertIn(self.mod_c_branch, deps) + self.assertNotIn(self.mod_d_branch, deps) + # Check that we get the expected dependencies + # We expect base, a, b, c (mod_d is excluded from its own dependencies) + self.assertEqual(len(deps), 4) + + def test_get_recursive_dependencies_complex_tree(self): + """Test _get_recursive_dependencies with complex dependency tree.""" + # Create module branches with complex dependencies: + # base + # ├── a + # │ ├── c + # │ └── d + # └── b + # └── e + self.mod_a_branch.dependency_ids = self.mod_base_branch + self.mod_b_branch.dependency_ids = self.mod_base_branch + self.mod_c_branch.dependency_ids = self.mod_a_branch + self.mod_d_branch.dependency_ids = self.mod_a_branch + self.mod_e_branch.dependency_ids = self.mod_b_branch + # Test from module_c + deps_c = self.mod_c_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps_c) + self.assertIn(self.mod_a_branch, deps_c) + self.assertNotIn(self.mod_b_branch, deps_c) + self.assertNotIn(self.mod_c_branch, deps_c) + self.assertNotIn(self.mod_d_branch, deps_c) + self.assertNotIn(self.mod_e_branch, deps_c) + self.assertEqual(len(deps_c), 2) # base, module_a + # Test from module_e + deps_e = self.mod_e_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps_e) + self.assertIn(self.mod_b_branch, deps_e) + self.assertNotIn(self.mod_a_branch, deps_e) + self.assertNotIn(self.mod_c_branch, deps_e) + self.assertNotIn(self.mod_d_branch, deps_e) + self.assertEqual(len(deps_e), 2) # base, module_b + + def test_get_recursive_dependencies_empty(self): + """Test _get_recursive_dependencies with no dependencies.""" + # Create module with no dependencies + mod_alone = self._create_odoo_module("module_alone") + mod_alone_branch = self._create_odoo_module_branch(mod_alone, self.branch) + # Test recursive dependencies + deps = mod_alone_branch._get_recursive_dependencies() + self.assertEqual(len(deps), 0) + + def test_get_recursive_dependencies_self_exclusion(self): + """Test that _get_recursive_dependencies excludes self.""" + # Create modules + mod_self = self._create_odoo_module("module_self") + # Create module branches + mod_self_branch = self._create_odoo_module_branch( + mod_self, self.branch, dependency_ids=[(4, self.mod_base_branch.id)] + ) + # Test recursive dependencies - self should not be included + deps = mod_self_branch._get_recursive_dependencies() + self.assertIn(self.mod_base_branch, deps) + self.assertNotIn(mod_self_branch, deps) + self.assertEqual(len(deps), 1) From b2f70e4b6c6a1532e922352a18f633b8e7960629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 1 Mar 2026 09:16:02 +0100 Subject: [PATCH 146/160] [FIX] odoo_repository: fix tests cleanup and remove unused code Following up 6000479e0ea54398f13cb3909b9516859802d886 --- odoo_repository/tests/common.py | 14 +++++++++++++- odoo_repository/tests/odoo_repo_mixin.py | 18 +----------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 0564a848..ccadbe49 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -5,6 +5,7 @@ import logging import pathlib import re +import shutil import tempfile from unittest.mock import patch @@ -28,7 +29,7 @@ def setUpClass(cls): "odoo_repository_storage_path", cls.repositories_path ) # org and repository - cls.repo_name = pathlib.Path(cls.repo_upstream_path).parts[-1] + cls.repo_name = 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( { @@ -180,3 +181,14 @@ def kill_remaining_git_processes(): psutil.wait_procs(children, timeout=10) cls.addClassCleanup(kill_remaining_git_processes) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + shutil.rmtree(cls.repositories_path) + + def tearDown(self): + super().tearDown() + repositories_path = pathlib.Path(self.repositories_path) + for sub_path in repositories_path.iterdir(): + shutil.rmtree(sub_path) diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py index 40cee150..3fad1991 100644 --- a/odoo_repository/tests/odoo_repo_mixin.py +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -35,9 +35,6 @@ def setUpClass(cls): 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 - 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): @@ -101,12 +98,6 @@ def _create_tmp_git_repository(cls) -> Path: git.Repo.init(repo_path) return Path(repo_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) - @classmethod def _fill_git_repository(cls, repo_path: Path, addon_path: Path): """Create branches with some content in the Git repository.""" @@ -163,15 +154,8 @@ def _create_module(cls, module_path: Path): with open(manifest_path, "w") as manifest: manifest.writelines(manifest_lines) - @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(cls.fork_org, repo.remotes.origin.url) - @classmethod def tearDownClass(cls): super().tearDownClass() - # Clean up the Git repository + # Clean up upstream Git repository shutil.rmtree(cls.repo_upstream_path) - shutil.rmtree(cls.repo_path) From 016a6ca9d46282e74b5bf6c1456acd98c8314e51 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 1 Mar 2026 08:40:28 +0000 Subject: [PATCH 147/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index f54ffcaf..71156caf 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:328550e69430ad717a18f199a15539f4494c9c1ff458e970b1a5bf0870a28d00 + !! source digest: sha256:b113f28039c1c754234a6a21b561b4a7fa127c183ea5ed12ac32e16740c66520 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index c966ae0e..271a0fd3 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.1", + "version": "18.0.1.1.2", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 57041a25..1efb0017 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:328550e69430ad717a18f199a15539f4494c9c1ff458e970b1a5bf0870a28d00 +!! source digest: sha256:b113f28039c1c754234a6a21b561b4a7fa127c183ea5ed12ac32e16740c66520 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From 723b441d818e363a02bdc7a495295775c6ae3634 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 24 Mar 2026 12:07:56 +0100 Subject: [PATCH 148/160] [DEL] explicit commits, they are not allowed in queue jobs --- odoo_repository/utils/scanner.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/odoo_repository/utils/scanner.py b/odoo_repository/utils/scanner.py index 954b80e0..a327704d 100644 --- a/odoo_repository/utils/scanner.py +++ b/odoo_repository/utils/scanner.py @@ -1,8 +1,6 @@ # Copyright 2023 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo.tools import config - from ..lib.scanner import RepositoryScanner @@ -75,16 +73,10 @@ def _push_scanned_data(self, repo_branch_id, module, data): res = self.env["odoo.module.branch"].push_scanned_data( repo_branch_id, module, data ) - # Commit after each module - if not config["test_enable"]: - self.env.cr.commit() # pylint: disable=invalid-commit return res def _update_last_scanned_commit(self, repo_branch_id, last_fetched_commit): repo_branch_model = self.env["odoo.repository.branch"] repo_branch = repo_branch_model.browse(repo_branch_id) repo_branch.last_scanned_commit = last_fetched_commit - # Commit after each repository/branch - if not config["test_enable"]: - self.env.cr.commit() # pylint: disable=invalid-commit return True From 7d2fb66a3ff29ee6f8ffee6f4c0cb23f2002f58f Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 27 Mar 2026 16:28:10 +0000 Subject: [PATCH 149/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 71156caf..585f64be 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:b113f28039c1c754234a6a21b561b4a7fa127c183ea5ed12ac32e16740c66520 + !! source digest: sha256:d15d89bb790e1ca0a2c85ca3f97e270bed65c575bbfaceaa7572a4b1c06000ce !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 271a0fd3..e0c2f4f6 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.2", + "version": "18.0.1.1.3", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 1efb0017..f40915ed 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:b113f28039c1c754234a6a21b561b4a7fa127c183ea5ed12ac32e16740c66520 +!! source digest: sha256:d15d89bb790e1ca0a2c85ca3f97e270bed65c575bbfaceaa7572a4b1c06000ce !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From f3d2a8435feba7e6b9e4616548a82f813e62e681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 30 Mar 2026 14:05:28 +0200 Subject: [PATCH 150/160] [FIX] odoo_repository: tests, apply git config in repository --- odoo_repository/tests/odoo_repo_mixin.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py index 3fad1991..160355c0 100644 --- a/odoo_repository/tests/odoo_repo_mixin.py +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -2,7 +2,6 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import io -import os import shutil import tempfile import threading @@ -31,20 +30,10 @@ def setUpClass(cls): cls.addon = "my_module" cls.target_addon = "my_module_renamed" # Create a temporary Git repository - 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" - @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'") - @classmethod def _get_upstream_repository_path(cls) -> Path: """Returns the path of upstream repository. @@ -95,7 +84,9 @@ def _unarchive_upstream_repository(cls, archive_data: bytes) -> Path: def _create_tmp_git_repository(cls) -> Path: """Create a temporary Git repository to run tests.""" repo_path = tempfile.mkdtemp() - git.Repo.init(repo_path) + repo = git.Repo.init(repo_path) + repo.config_writer().set_value("user", "name", "test").release() + repo.config_writer().set_value("user", "email", "test@example.com").release() return Path(repo_path) @classmethod From 5f0cdf37fc38774ca92c9b3a00f1a11224e2159c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 10 Apr 2026 09:44:53 +0200 Subject: [PATCH 151/160] [FIX] odoo_repository: move setUp content to setUpClass --- odoo_repository/tests/common.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index ccadbe49..3fcf1272 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -55,28 +55,25 @@ def setUpClass(cls): ) cls.branch.active = True cls._handle_cleanup() - - def setUp(self): - super().setUp() # branch2 - self.branch2_name = self.source2.split("/")[1] - self.branch2 = ( - self.env["odoo.branch"] + cls.branch2_name = cls.source2.split("/")[1] + cls.branch2 = ( + cls.env["odoo.branch"] .with_context(active_test=False) - .search([("name", "=", self.branch2_name)]) + .search([("name", "=", cls.branch2_name)]) ) - if not self.branch2: - self.branch2 = self.env["odoo.branch"].create( + if not cls.branch2: + cls.branch2 = cls.env["odoo.branch"].create( { - "name": self.branch2_name, + "name": cls.branch2_name, } ) - self.branch2.active = True + cls.branch2.active = True # branch3 - self.branch3_name = self.target2.split("/")[1] + cls.branch3_name = cls.target2.split("/")[1] # technical module - self.module_name = self.addon - self.module_branch_model = self.env["odoo.module.branch"] + cls.module_name = cls.addon + cls.module_branch_model = cls.env["odoo.module.branch"] def _patch_github_class(self): # Patch helper method part of 'odoo_repository' module From eb38b14e8c63ffec40588bb6f3f84b809d73200e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 10 Apr 2026 08:17:22 +0000 Subject: [PATCH 152/160] [BOT] post-merge updates --- odoo_repository/README.rst | 2 +- odoo_repository/__manifest__.py | 2 +- odoo_repository/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 585f64be..bf3dd394 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -11,7 +11,7 @@ Odoo Repositories Data !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:d15d89bb790e1ca0a2c85ca3f97e270bed65c575bbfaceaa7572a4b1c06000ce + !! source digest: sha256:271386dd2fbe5bf2e89d69cdd6363a949f64d83a757a11a13795f12d0d9a7734 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index e0c2f4f6..921ac2e7 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo Repositories Data", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.3", + "version": "18.0.1.1.4", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index f40915ed..45e897e5 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -372,7 +372,7 @@

    Odoo Repositories Data

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:d15d89bb790e1ca0a2c85ca3f97e270bed65c575bbfaceaa7572a4b1c06000ce +!! source digest: sha256:271386dd2fbe5bf2e89d69cdd6363a949f64d83a757a11a13795f12d0d9a7734 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    From 815fbd61adcf1a5d681dcc0347fe7e34ea55c02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 27 May 2026 14:25:46 +0200 Subject: [PATCH 153/160] [IMP] odoo_repository: add .previous_id field --- odoo_repository/README.rst | 12 +++----- odoo_repository/__manifest__.py | 4 +-- odoo_repository/models/odoo_branch.py | 13 +++++++-- odoo_repository/static/description/index.html | 24 ++++++---------- odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/common.py | 12 ++++++++ odoo_repository/tests/test_odoo_branch.py | 28 +++++++++++++++++++ 7 files changed, 67 insertions(+), 27 deletions(-) create mode 100644 odoo_repository/tests/test_odoo_branch.py diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index bf3dd394..3a0696bb 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -1,10 +1,6 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - -====================== -Odoo Repositories Data -====================== +======== +Odoo MCA +======== .. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -17,7 +13,7 @@ Odoo Repositories Data .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmodule--composition--analysis-lightgray.png?logo=github diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 921ac2e7..34e76314 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -2,9 +2,9 @@ # Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) { - "name": "Odoo Repositories Data", + "name": "Odoo MCA", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.4", + "version": "18.0.1.2.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 31af62cb..6bf6e4d2 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -31,7 +31,11 @@ class OdooBranch(models.Model): sequence = fields.Integer() next_id = fields.Many2one( comodel_name="odoo.branch", - compute="_compute_next_id", + compute="_compute_next_and_previous", + ) + previous_id = fields.Many2one( + comodel_name="odoo.branch", + compute="_compute_next_and_previous", ) _sql_constraints = [ @@ -47,13 +51,18 @@ def _constrains_name(self): raise ValidationError(_("Version must match the pattern 'x.y'.")) @api.depends("sequence") - def _compute_next_id(self): + def _compute_next_and_previous(self): for rec in self: rec.next_id = self.search( [("sequence", ">", rec.sequence)], order="sequence", limit=1, ) + rec.previous_id = self.search( + [("sequence", "<", rec.sequence)], + order="sequence DESC", + limit=1, + ) @api.model def _recompute_sequence(self): diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 45e897e5..23cdc23d 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Odoo MCA -
    +
    +

    Odoo MCA

    - - -Odoo Community Association - -
    -

    Odoo Repositories Data

    -

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    +

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    It allows you to:

      @@ -397,7 +392,7 @@

      Odoo Repositories Data

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -405,15 +400,15 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Camptocamp
    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -435,6 +430,5 @@

    Maintainers

    -
    diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index ee060d5d..ac60db36 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -4,5 +4,6 @@ from . import test_odoo_repository_scan from . import test_sync_node from . import test_odoo_module_branch +from . import test_odoo_branch from . import test_oca_repository_synchronizer from . import test_odoo_module_branch_recursive_dependencies diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 3fcf1272..4ecefc0d 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -71,6 +71,18 @@ def setUpClass(cls): cls.branch2.active = True # branch3 cls.branch3_name = cls.target2.split("/")[1] + cls.branch3 = ( + cls.env["odoo.branch"] + .with_context(active_test=False) + .search([("name", "=", cls.branch3_name)]) + ) + if not cls.branch3: + cls.branch3 = cls.env["odoo.branch"].create( + { + "name": cls.branch2_name, + } + ) + cls.branch3.active = True # technical module cls.module_name = cls.addon cls.module_branch_model = cls.env["odoo.module.branch"] diff --git a/odoo_repository/tests/test_odoo_branch.py b/odoo_repository/tests/test_odoo_branch.py new file mode 100644 index 00000000..671260f5 --- /dev/null +++ b/odoo_repository/tests/test_odoo_branch.py @@ -0,0 +1,28 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestOdooBranch(Common): + def test_next_id_previous_id(self): + """Test next_id and previous_id computed fields.""" + branch_model = self.env["odoo.branch"] + branches = branch_model.search([], order="sequence", limit=3) + first_branch, second_branch, third_branch = ( + branches[0], + branches[1], + branches[2], + ) + # Test middle branch + self.assertEqual(second_branch.previous_id, first_branch) + self.assertEqual(second_branch.next_id, third_branch) + # Test first branch + self.assertFalse(first_branch.previous_id) + self.assertEqual(first_branch.next_id, second_branch) + # Test last branch + branches = branch_model.search([], order="sequence DESC", limit=2) + last_branch, ante_branch = branches[0], branches[1] + self.assertEqual(last_branch.previous_id, ante_branch) + self.assertFalse(last_branch.next_id) From 95bebf0eb1b5cc6e39c8c4e3f377beb70ecdd801 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Sat, 13 Jun 2026 13:02:44 +0000 Subject: [PATCH 154/160] [UPD] Update odoo_repository.pot --- odoo_repository/i18n/odoo_repository.pot | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/odoo_repository/i18n/odoo_repository.pot b/odoo_repository/i18n/odoo_repository.pot index fa9dc088..7530dbcb 100644 --- a/odoo_repository/i18n/odoo_repository.pot +++ b/odoo_repository/i18n/odoo_repository.pot @@ -868,6 +868,11 @@ msgstr "" msgid "PR URL" msgstr "" +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__previous_id +msgid "Previous" +msgstr "" + #. module: odoo_repository #: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__private_key msgid "Private Key" From b6ba9ad1b50fc3cfe3a9bdc911b4c691917bfa5a Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 13 Jun 2026 13:04:57 +0000 Subject: [PATCH 155/160] [BOT] post-merge updates --- odoo_repository/README.rst | 8 ++++-- odoo_repository/static/description/index.html | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index 3a0696bb..c4223deb 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ======== Odoo MCA ======== @@ -7,13 +11,13 @@ Odoo MCA !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:271386dd2fbe5bf2e89d69cdd6363a949f64d83a757a11a13795f12d0d9a7734 + !! source digest: sha256:71e3b6009a205241f59db8ab094782861157f514480a57b630abc8e1a10c8d92 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmodule--composition--analysis-lightgray.png?logo=github diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 23cdc23d..65c00b8e 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -3,7 +3,7 @@ -Odoo MCA +README.rst -
    -

    Odoo MCA

    +
    + + +Odoo Community Association + +
    +

    Odoo MCA

    -

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    +

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    It allows you to:

      @@ -392,7 +397,7 @@

      Odoo MCA

    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -400,15 +405,15 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Camptocamp
    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -430,5 +435,6 @@

    Maintainers

    +
    From c1c5f8b894d7ff421da91e745ba2094c7c078da8 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sat, 13 Jun 2026 13:05:10 +0000 Subject: [PATCH 156/160] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: module-composition-analysis-18.0/module-composition-analysis-18.0-odoo_repository Translate-URL: https://translation.odoo-community.org/projects/module-composition-analysis-18-0/module-composition-analysis-18-0-odoo_repository/ --- odoo_repository/i18n/it.po | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/odoo_repository/i18n/it.po b/odoo_repository/i18n/it.po index 61f9afe9..9abbf0a3 100644 --- a/odoo_repository/i18n/it.po +++ b/odoo_repository/i18n/it.po @@ -868,6 +868,11 @@ msgstr "" msgid "PR URL" msgstr "" +#. module: odoo_repository +#: model:ir.model.fields,field_description:odoo_repository.field_odoo_branch__previous_id +msgid "Previous" +msgstr "" + #. module: odoo_repository #: model:ir.model.fields,field_description:odoo_repository.field_ssh_key__private_key msgid "Private Key" From d6fb9b255154c54743d123e3bf13af2faa7dc0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Wed, 29 Jul 2026 06:40:23 -0600 Subject: [PATCH 157/160] [MIG] odoo_repository: Migration to 19.0 --- odoo_repository/README.rst | 10 +-- odoo_repository/__manifest__.py | 2 +- .../migrations/18.0.1.1.0/pre-migration.py | 48 -------------- odoo_repository/models/odoo_author.py | 7 ++- odoo_repository/models/odoo_branch.py | 18 ++++-- odoo_repository/models/odoo_license.py | 7 ++- odoo_repository/models/odoo_maintainer.py | 7 ++- odoo_repository/models/odoo_module.py | 7 ++- odoo_repository/models/odoo_module_branch.py | 37 +++++------ .../models/odoo_module_branch_version.py | 11 ++-- .../models/odoo_module_category.py | 7 ++- .../models/odoo_module_dev_status.py | 7 ++- .../models/odoo_python_dependency.py | 7 ++- odoo_repository/models/odoo_repository.py | 38 +++++++----- .../models/odoo_repository_addons_path.py | 11 ++-- .../models/odoo_repository_branch.py | 11 ++-- odoo_repository/models/odoo_repository_org.py | 7 ++- odoo_repository/security/res_groups.xml | 10 ++- odoo_repository/static/description/index.html | 6 +- .../views/authentication_token.xml | 8 --- odoo_repository/views/odoo_module_branch.xml | 62 +++++++++---------- odoo_repository/views/odoo_repository.xml | 12 ++-- odoo_repository/views/res_config_settings.xml | 1 - odoo_repository/views/ssh_key.xml | 8 --- requirements.txt | 4 ++ 25 files changed, 148 insertions(+), 205 deletions(-) delete mode 100644 odoo_repository/migrations/18.0.1.1.0/pre-migration.py create mode 100644 requirements.txt diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index c4223deb..3d4ba9cd 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -21,13 +21,13 @@ Odoo MCA :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmodule--composition--analysis-lightgray.png?logo=github - :target: https://github.com/OCA/module-composition-analysis/tree/18.0/odoo_repository + :target: https://github.com/OCA/module-composition-analysis/tree/19.0/odoo_repository :alt: OCA/module-composition-analysis .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/module-composition-analysis-18-0/module-composition-analysis-18-0-odoo_repository + :target: https://translation.odoo-community.org/projects/module-composition-analysis-19-0/module-composition-analysis-19-0-odoo_repository :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/module-composition-analysis&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/module-composition-analysis&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -53,7 +53,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -85,6 +85,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/module-composition-analysis `_ project on GitHub. +This module is part of the `OCA/module-composition-analysis `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 34e76314..4ccaf455 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Odoo MCA", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.2.0", + "version": "19.0.1.0.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/migrations/18.0.1.1.0/pre-migration.py b/odoo_repository/migrations/18.0.1.1.0/pre-migration.py deleted file mode 100644 index ac69c5be..00000000 --- a/odoo_repository/migrations/18.0.1.1.0/pre-migration.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2026 Sébastien Alix -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) - -import logging - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not version: - return - remove_xmlids_from_oca_repositories(cr) - - -def remove_xmlids_from_oca_repositories(cr): - """Remove XML-IDs from OCA repositories to prevent deletion during upgrade.""" - _logger.info("Removing XML-IDs from OCA repositories...") - # First, get the OCA organization ID - cr.execute("SELECT id FROM odoo_repository_org WHERE name = 'OCA'") - oca_org_result = cr.fetchone() - if not oca_org_result: - _logger.warning("OCA organization not found in database") - return - oca_org_id = oca_org_result[0] - # Get all OCA repositories that have XML-IDs - cr.execute( - """ - SELECT imd.id, imd.res_id - FROM ir_model_data imd - JOIN odoo_repository repo ON imd.res_id = repo.id - WHERE imd.model = 'odoo.repository' - AND repo.org_id = %s - """, - (oca_org_id,), - ) - xmlid_records = cr.fetchall() - if not xmlid_records: - _logger.info("No OCA repositories with XML-IDs found") - return - # Delete the XML-IDs from ir.model.data - xmlid_ids = [record[0] for record in xmlid_records] - repository_ids = [record[1] for record in xmlid_records] - cr.execute("DELETE FROM ir_model_data WHERE id = ANY(%s)", (xmlid_ids,)) - _logger.info( - "Removed %s XML-IDs from OCA repositories (repository IDs: %s)", - len(xmlid_ids), - repository_ids, - ) diff --git a/odoo_repository/models/odoo_author.py b/odoo_repository/models/odoo_author.py index d2eeb8eb..43d134bf 100644 --- a/odoo_repository/models/odoo_author.py +++ b/odoo_repository/models/odoo_author.py @@ -11,6 +11,7 @@ class OdooAuthor(models.Model): name = fields.Char(required=True, index=True) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This author already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This author already exists.", + ) diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 6bf6e4d2..5fe1e1c4 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -3,7 +3,7 @@ import re -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import ValidationError @@ -38,9 +38,10 @@ class OdooBranch(models.Model): compute="_compute_next_and_previous", ) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This branch already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This branch already exists.", + ) @api.constrains("name") def _constrains_name(self): @@ -48,7 +49,9 @@ def _constrains_name(self): for rec in self: version = re.search(odoo_version_pattern, rec.name) if not version: - raise ValidationError(_("Version must match the pattern 'x.y'.")) + raise ValidationError( + self.env._("Version must match the pattern 'x.y'.") + ) @api.depends("sequence") def _compute_next_and_previous(self): @@ -117,4 +120,7 @@ def action_force_scan(self): def _get_all_odoo_versions(self, active_test=False): """Return all Odoo versions, even archived ones.""" - return self.with_context(active_test=active_test).search([]) + # All Odoo versions are a few dozen records at most + return self.with_context( # pylint: disable=no-search-all + active_test=active_test + ).search([]) diff --git a/odoo_repository/models/odoo_license.py b/odoo_repository/models/odoo_license.py index d6056865..7c3bfbe2 100644 --- a/odoo_repository/models/odoo_license.py +++ b/odoo_repository/models/odoo_license.py @@ -11,6 +11,7 @@ class OdooLicense(models.Model): name = fields.Char(required=True, index=True) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This license already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This license already exists.", + ) diff --git a/odoo_repository/models/odoo_maintainer.py b/odoo_repository/models/odoo_maintainer.py index 2367369a..709be0a1 100644 --- a/odoo_repository/models/odoo_maintainer.py +++ b/odoo_repository/models/odoo_maintainer.py @@ -26,6 +26,7 @@ def _compute_github_url(self): for rec in self: rec.github_url = f"{GITHUB_URL}/{rec.name}" - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This maintainer already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This maintainer already exists.", + ) diff --git a/odoo_repository/models/odoo_module.py b/odoo_repository/models/odoo_module.py index 24c8ba9c..4d913a1a 100644 --- a/odoo_repository/models/odoo_module.py +++ b/odoo_repository/models/odoo_module.py @@ -19,6 +19,7 @@ class OdooModule(models.Model): help="Blacklisted modules won't be scanned.", readonly=True ) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This module technical name already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This module technical name already exists.", + ) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 2229d0f4..f35661a0 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -6,9 +6,9 @@ import time from urllib.parse import urlparse -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import ValidationError -from odoo.osv import expression +from odoo.fields import Domain from odoo.addons.queue_job.exception import RetryableJobError @@ -185,13 +185,10 @@ class OdooModuleBranch(models.Model): ) ) - _sql_constraints = [ - ( - "module_id_branch_id_repository_id_uniq", - "UNIQUE (module_id, branch_id, repository_id)", - "This module already exists for this repository/branch.", - ), - ] + _module_id_branch_id_repository_id_uniq = models.Constraint( + "UNIQUE (module_id, branch_id, repository_id)", + "This module already exists for this repository/branch.", + ) def init(self): # Index to complete unique constraint 'module_id_branch_id_repository_id_uniq'. @@ -217,7 +214,7 @@ def init(self): # """ ] for index in indexes: - self._cr.execute(index) + self.env.cr.execute(index) @api.constrains("specific", "dependency_ids") def _check_generic_depends_on_specific(self): @@ -225,18 +222,15 @@ def _check_generic_depends_on_specific(self): if not rec.specific: specific_deps = rec.dependency_ids.filtered("specific") if specific_deps: - msg = _( - "Generic module %(generic_mod)s cannot depend " - "on specific module(s): %(specific_mods)s" - ) raise ValidationError( - msg - % { - "generic_mod": rec.module_name, - "specific_mods": ", ".join( + self.env._( + "Generic module %(generic_mod)s cannot depend " + "on specific module(s): %(specific_mods)s", + generic_mod=rec.module_name, + specific_mods=", ".join( specific_deps.mapped("module_name") ), - } + ) ) @api.depends("module_name", "addons_path") @@ -387,7 +381,6 @@ def _find_repository_from_pr_url(self, pr_url): ) @api.model - @api.returns("odoo.module.branch") def push_scanned_data(self, repo_branch_id, module, data): """Entry point for the scanner to push its data.""" module = self._get_module(module) @@ -718,14 +711,14 @@ def _find(self, branch, module, repo, domain=None): modules_branch = self._get_module_branch( branch, module, - domain=expression.AND( + domain=Domain.AND( [ domain or [], [("specific", "=", False), ("repository_id", "!=", False)], ], ), ) - module_branch = fields.first(modules_branch) + module_branch = modules_branch[:1] # Otherwise look for the module among orphaned modules if not module_branch: module_branch = self._get_orphaned_module_branch( diff --git a/odoo_repository/models/odoo_module_branch_version.py b/odoo_repository/models/odoo_module_branch_version.py index 4e96bc25..990841df 100644 --- a/odoo_repository/models/odoo_module_branch_version.py +++ b/odoo_repository/models/odoo_module_branch_version.py @@ -49,13 +49,10 @@ class OdooModuleBranchVersion(models.Model): ) sequence = fields.Integer() - _sql_constraints = [ - ( - "module_branch_id_name_manifest_value_uniq", - "UNIQUE (module_branch_id, name, manifest_value)", - "This version already exists for this module.", - ), - ] + _module_branch_id_name_manifest_value_uniq = models.Constraint( + "UNIQUE (module_branch_id, name, manifest_value)", + "This version already exists for this module.", + ) @api.model_create_multi def create(self, vals_list): diff --git a/odoo_repository/models/odoo_module_category.py b/odoo_repository/models/odoo_module_category.py index a9572089..90999533 100644 --- a/odoo_repository/models/odoo_module_category.py +++ b/odoo_repository/models/odoo_module_category.py @@ -10,6 +10,7 @@ class OdooModuleCategory(models.Model): name = fields.Char(required=True, index=True) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This module category already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This module category already exists.", + ) diff --git a/odoo_repository/models/odoo_module_dev_status.py b/odoo_repository/models/odoo_module_dev_status.py index 908a3a1e..1ab3a7d8 100644 --- a/odoo_repository/models/odoo_module_dev_status.py +++ b/odoo_repository/models/odoo_module_dev_status.py @@ -10,6 +10,7 @@ class OdooModuleDevStatus(models.Model): name = fields.Char(required=True, index=True) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This development_status already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This development_status already exists.", + ) diff --git a/odoo_repository/models/odoo_python_dependency.py b/odoo_repository/models/odoo_python_dependency.py index 27140253..8c7883e9 100644 --- a/odoo_repository/models/odoo_python_dependency.py +++ b/odoo_repository/models/odoo_python_dependency.py @@ -11,6 +11,7 @@ class OdooPythonDependency(models.Model): name = fields.Char(required=True, index=True) - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This Python dependency already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This Python dependency already exists.", + ) diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 71bbeba1..12224a5c 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -10,9 +10,9 @@ import requests -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import UserError -from odoo.osv.expression import AND, OR +from odoo.fields import Domain from odoo.addons.queue_job.delay import chain from odoo.addons.queue_job.exception import RetryableJobError @@ -137,13 +137,10 @@ def _compute_github_url(self): for rec in self: rec.github_url = f"{rec.org_id.github_url}/{rec.name}" - _sql_constraints = [ - ( - "org_id_name_uniq", - "UNIQUE (org_id, name)", - "This repository already exists.", - ), - ] + _org_id_name_uniq = models.Constraint( + "UNIQUE (org_id, name)", + "This repository already exists.", + ) @api.depends("org_id.name", "name") def _compute_display_name(self): @@ -171,10 +168,10 @@ def _get_odoo_branches_to_scan(self): def _cron_scanner_domain(self): today = fields.Date.today() weekday = today.weekday() - return AND( + return Domain.AND( [ [("to_scan", "=", True)], - OR( + Domain.OR( [ [("scan_weekday_ids.name", "=", weekday)], [("scan_weekday_ids", "=", False)], @@ -207,9 +204,10 @@ def _check_config(self): repositories_path = self.env["ir.config_parameter"].sudo().get_param(key, "") if not repositories_path: raise UserError( - _( - f"Please define the '{key}' system parameter to " - "clone repositories in the folder of your choice." + self.env._( + "Please define the '%(key)s' system parameter to " + "clone repositories in the folder of your choice.", + key=key, ) ) # Ensure the folder exists @@ -240,7 +238,9 @@ def _check_existing_jobs(self, raise_exc=True): ) ) if existing_job: - msg = _("A scan is already ongoing for repository %s") % self.display_name + msg = self.env._( + "A scan is already ongoing for repository %s", self.display_name + ) if raise_exc: raise UserError(msg) _logger.warning(msg) @@ -480,14 +480,18 @@ def cron_fetch_data(self, branches=None, force=False): try: response = requests.get(url, timeout=60) except Exception as exc: - raise UserError(_("Unable to fetch data from %s") % main_node_url) from exc + raise UserError( + self.env._("Unable to fetch data from %s", main_node_url) + ) from exc else: if response.status_code == 200: try: data = json.loads(response.text) except json.decoder.JSONDecodeError as exc: raise UserError( - _("Unable to decode data received from %s") % main_node_url + self.env._( + "Unable to decode data received from %s", main_node_url + ) ) from exc else: self._import_data(data) diff --git a/odoo_repository/models/odoo_repository_addons_path.py b/odoo_repository/models/odoo_repository_addons_path.py index 7c8d192a..6c078d15 100644 --- a/odoo_repository/models/odoo_repository_addons_path.py +++ b/odoo_repository/models/odoo_repository_addons_path.py @@ -31,10 +31,7 @@ class OdooRepositoryAddonsPath(models.Model): default=False, ) - _sql_constraints = [ - ( - "addons_path_uniq", - "UNIQUE (relative_path, is_standard, is_enterprise, is_community)", - "This addons-path already exists.", - ), - ] + _addons_path_uniq = models.Constraint( + "UNIQUE (relative_path, is_standard, is_enterprise, is_community)", + "This addons-path already exists.", + ) diff --git a/odoo_repository/models/odoo_repository_branch.py b/odoo_repository/models/odoo_repository_branch.py index d748c2a8..b3c0f13d 100644 --- a/odoo_repository/models/odoo_repository_branch.py +++ b/odoo_repository/models/odoo_repository_branch.py @@ -47,13 +47,10 @@ class OdooRepositoryBranch(models.Model): last_scanned_commit = fields.Char(readonly=True) active = fields.Boolean(compute="_compute_active", store=True) - _sql_constraints = [ - ( - "repository_id_branch_id_uniq", - "UNIQUE (repository_id, branch_id)", - "This branch already exists for this repository.", - ), - ] + _repository_id_branch_id_uniq = models.Constraint( + "UNIQUE (repository_id, branch_id)", + "This branch already exists for this repository.", + ) @api.depends("repository_id.display_name", "branch_id.name") def _compute_name(self): diff --git a/odoo_repository/models/odoo_repository_org.py b/odoo_repository/models/odoo_repository_org.py index 38605aa2..61a600bb 100644 --- a/odoo_repository/models/odoo_repository_org.py +++ b/odoo_repository/models/odoo_repository_org.py @@ -18,6 +18,7 @@ def _compute_github_url(self): for rec in self: rec.github_url = f"{GITHUB_URL}/{rec.name}" - _sql_constraints = [ - ("name_uniq", "UNIQUE (name)", "This organization already exists."), - ] + _name_uniq = models.Constraint( + "UNIQUE (name)", + "This organization already exists.", + ) diff --git a/odoo_repository/security/res_groups.xml b/odoo_repository/security/res_groups.xml index ca6fa70e..830e2e08 100644 --- a/odoo_repository/security/res_groups.xml +++ b/odoo_repository/security/res_groups.xml @@ -7,14 +7,20 @@ 50
    + + Odoo MCA + 50 + + + Odoo MCA User - + Odoo MCA Manager - + Odoo MCA !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:71e3b6009a205241f59db8ab094782861157f514480a57b630abc8e1a10c8d92 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    +

    Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

    Base module to host data collected from Odoo repositories.

    It allows you to:

      @@ -401,7 +401,7 @@

      Bug Tracker

      Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

      +feedback.

      Do not contact contributors directly about support or help with technical issues.

    @@ -430,7 +430,7 @@

    Maintainers

    OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

    -

    This module is part of the OCA/module-composition-analysis project on GitHub.

    +

    This module is part of the OCA/module-composition-analysis project on GitHub.

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    diff --git a/odoo_repository/views/authentication_token.xml b/odoo_repository/views/authentication_token.xml index 2cc1f4dd..eb75c028 100644 --- a/odoo_repository/views/authentication_token.xml +++ b/odoo_repository/views/authentication_token.xml @@ -5,10 +5,6 @@ authentication.token.form authentication.token -
    @@ -47,10 +43,6 @@ ir.actions.act_window authentication.token - - - - - - - - - + + + + + + diff --git a/odoo_repository/views/odoo_repository.xml b/odoo_repository/views/odoo_repository.xml index a2ed22ad..ab4d7647 100644 --- a/odoo_repository/views/odoo_repository.xml +++ b/odoo_repository/views/odoo_repository.xml @@ -146,13 +146,11 @@ string="Archived" domain="[('active', '=', False)]" /> - - - + diff --git a/odoo_repository/views/res_config_settings.xml b/odoo_repository/views/res_config_settings.xml index 3367ccc7..19f41254 100644 --- a/odoo_repository/views/res_config_settings.xml +++ b/odoo_repository/views/res_config_settings.xml @@ -74,7 +74,6 @@ res.config.settings form - inline {'module' : 'odoo_repository', 'bin_size': False} diff --git a/odoo_repository/views/ssh_key.xml b/odoo_repository/views/ssh_key.xml index 47e2ede3..565ba88a 100644 --- a/odoo_repository/views/ssh_key.xml +++ b/odoo_repository/views/ssh_key.xml @@ -5,10 +5,6 @@ ssh.key.form ssh.key - @@ -47,10 +43,6 @@ ir.actions.act_window ssh.key - Date: Wed, 5 Aug 2026 10:25:10 -0600 Subject: [PATCH 158/160] [FIX] odoo_repository: wait for GitHub API rate limit reset When the GitHub API rate limit is reached (403/429 responses), jobs were retried with the standard retry pattern until reaching the max. retries, ending up as failed jobs and spamming administrators with emails while still hammering the API for each repository/module. Detect rate limit responses (Retry-After / X-RateLimit-* headers) and postpone the job until the limit is reset, without increasing its retry counter. Co-Authored-By: Claude Fable 5 --- .../components/oca_repository_synchronizer.py | 8 ++++ odoo_repository/models/odoo_module_branch.py | 8 ++++ odoo_repository/tests/test_utils.py | 37 +++++++++++++++++++ odoo_repository/utils/github.py | 30 +++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/odoo_repository/components/oca_repository_synchronizer.py b/odoo_repository/components/oca_repository_synchronizer.py index b56e37e7..b2d27a41 100644 --- a/odoo_repository/components/oca_repository_synchronizer.py +++ b/odoo_repository/components/oca_repository_synchronizer.py @@ -62,6 +62,14 @@ def run(self): stats = self._sync_oca_repositories(current_repos) _logger.info("OCA repository sync completed: %s", stats) return stats + except github.GitHubRateLimitError as e: + # Postpone the job until the rate limit is reset, without + # increasing its retry counter + raise RetryableJobError( + "GitHub API rate limit reached, waiting for reset", + seconds=e.retry_after, + ignore_retry=True, + ) from e except Exception as e: raise RetryableJobError("Failed to fetch OCA repositories") from e diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index f35661a0..015f5f21 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -362,6 +362,14 @@ def _find_pr_urls_from_github(self, branch, module): # by adding a random waiting time of 1-4s time.sleep(random.randrange(1, 5)) prs = github.request(self.env, url) + except github.GitHubRateLimitError as exc: + # Postpone the job until the rate limit is reset, without + # increasing its retry counter + raise RetryableJobError( + "GitHub API rate limit reached, waiting for reset", + seconds=exc.retry_after, + ignore_retry=True, + ) from exc except RuntimeError as exc: raise RetryableJobError("Error while looking for PR URL") from exc for pr in prs.get("items", []): diff --git a/odoo_repository/tests/test_utils.py b/odoo_repository/tests/test_utils.py index 612ea72f..f0acd447 100644 --- a/odoo_repository/tests/test_utils.py +++ b/odoo_repository/tests/test_utils.py @@ -1,8 +1,12 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import time +from unittest import mock + from odoo.tests.common import TransactionCase +from odoo.addons.odoo_repository.utils.github import _get_retry_after from odoo.addons.odoo_repository.utils.module import adapt_version @@ -23,3 +27,36 @@ def test_adapt_version(self): self.assertEqual(adapt_version("14.0", ".1.0.0"), "14.0.1.0.0") self.assertEqual(adapt_version("14.0", "1.0.0."), "14.0.1.0.0") self.assertEqual(adapt_version("14.0", "...1.0.0..."), "14.0.1.0.0") + + def _response(self, status_code, headers=None, text=""): + response = mock.Mock() + response.status_code = status_code + response.headers = headers or {} + response.text = text + return response + + def test_get_retry_after(self): + # Not a rate limit status code + self.assertIsNone(_get_retry_after(self._response(404))) + self.assertIsNone(_get_retry_after(self._response(403))) + # Secondary rate limit with Retry-After header + response = self._response(429, headers={"Retry-After": "120"}) + self.assertEqual(_get_retry_after(response), 120) + # Primary rate limit with reset timestamp + response = self._response( + 403, + headers={ + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(int(time.time()) + 600), + }, + ) + self.assertAlmostEqual(_get_retry_after(response), 600, delta=5) + # Reset timestamp in the past: wait at least 60s + response = self._response( + 403, + headers={"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "0"}, + ) + self.assertEqual(_get_retry_after(response), 60) + # Rate limit detected only through the response body + response = self._response(403, text="API rate limit exceeded for ...") + self.assertEqual(_get_retry_after(response), 60) diff --git a/odoo_repository/utils/github.py b/odoo_repository/utils/github.py index 083e1b63..e8f3f003 100644 --- a/odoo_repository/utils/github.py +++ b/odoo_repository/utils/github.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os +import time import requests @@ -9,6 +10,32 @@ GITHUB_API_URL = "https://api.github.com" +class GitHubRateLimitError(RuntimeError): + """GitHub API rate limit reached. + + `retry_after` is the number of seconds to wait before retrying. + """ + + def __init__(self, message, retry_after): + super().__init__(message) + self.retry_after = retry_after + + +def _get_retry_after(response): + """Return seconds to wait if `response` is a rate limit error, else None.""" + if response.status_code not in (403, 429): + return None + headers = response.headers + if headers.get("Retry-After"): + return int(headers["Retry-After"]) + if headers.get("X-RateLimit-Remaining") == "0": + reset = int(headers.get("X-RateLimit-Reset", 0)) + return max(reset - int(time.time()), 60) + if "rate limit" in response.text.lower(): + return 60 + return None + + def request(env, url, method="get", params=None, json=None): """Request GitHub API.""" headers = {"Accept": "application/vnd.github.groot-preview+json"} @@ -26,5 +53,8 @@ def request(env, url, method="get", params=None, json=None): kwargs.update(params=params) response = getattr(requests, method)(full_url, **kwargs) if not response.ok: + retry_after = _get_retry_after(response) + if retry_after: + raise GitHubRateLimitError(response.text, retry_after) raise RuntimeError(response.text) return response.json() From 435c8ac48c33f2f40ac6ba509535de01af2a3edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Wed, 5 Aug 2026 11:07:55 -0600 Subject: [PATCH 159/160] [FIX] odoo_repository: scan of modules with a list as manifest author Some Odoo standard modules (e.g. l10n_ve 14.0) define the manifest 'author' key as a list. As _get_author_ids() is decorated with ormcache, the cache key hashing crashed with 'TypeError: unhashable type: list' before even entering the method, making the scan job fail. Convert the list to a tuple before calling the cached method. Co-Authored-By: Claude Fable 5 --- odoo_repository/models/odoo_module_branch.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 015f5f21..726d12d2 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -418,7 +418,12 @@ def _prepare_module_branch_values(self, repo_branch, module, data): } if manifest: category_id = self._get_module_category_id(manifest.get("category", "")) - author_ids = self._get_author_ids(manifest.get("author", "")) + # Some Odoo std modules have a list instead of a string as 'author': + # convert it to a tuple to keep the ormcache key hashable + author = manifest.get("author") or "" + if isinstance(author, list): + author = tuple(author) + author_ids = self._get_author_ids(author) maintainer_ids = self._get_maintainer_ids( tuple(manifest.get("maintainers", [])) ) From f8b1c7e6a9dff09cd7fbc2e4ae474bea9728d448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Wed, 5 Aug 2026 11:11:04 -0600 Subject: [PATCH 160/160] [FIX] odoo_repository: do not fail scan on module code analysis error Some modules ship files that odoo_addons_parser cannot handle, e.g. nested tags in XML data files that Odoo itself tolerates (l10n_ro_stock_account 14.0, auth_impersonate_user 17.0...). The scan job kept retrying and failing on them forever. Log a warning and push an empty code analysis instead, so the scan completes and the module is still registered with its manifest-less data. Co-Authored-By: Claude Fable 5 --- odoo_repository/lib/scanner.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index d06dfd67..62574ce4 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -1058,8 +1058,24 @@ def _run_module_code_analysis( ): """Perform a code analysis of `module_path`.""" # Get current code analysis data - parser = ModuleParser(f"{self.path}/{module_path}", scan_models=False) - data = parser.to_dict() + try: + parser = ModuleParser(f"{self.path}/{module_path}", scan_models=False) + data = parser.to_dict() + except Exception as exc: + # Some modules ship files the parser cannot handle (e.g. nested + # tags in XML, tolerated by Odoo): log and push an empty + # analysis instead of failing the scan forever. + _logger.warning( + "%s#%s: code analysis failed for '%s': %s", + self.full_name, + self.branch, + module_path, + exc, + ) + data = { + "manifest": {}, + "code": {"Python": 0, "XML": 0, "JavaScript": 0, "CSS": 0}, + } # Append the history of versions versions = self._read_module_versions( repo, module_path, branch, from_commit, to_commit