From 6a5e65b882c8810b4845cd4ee47d1612f1474fb8 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/134] 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 0000000..0650744 --- /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 0000000..f147fea --- /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 0000000..227feeb --- /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 0000000..01d3fe5 --- /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 0000000..7746393 --- /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 0000000..7d107d6 --- /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 0000000..dbbbce9 --- /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 0000000..4b6202d --- /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 0000000..07208b9 --- /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 0000000..e69de29 diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py new file mode 100644 index 0000000..bf678b7 --- /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 0000000..efb1e5c --- /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 0000000..918f94c --- /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 0000000..65d23cd --- /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 0000000..fd85f8d --- /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 0000000..f57d0cc --- /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 0000000..f2a06bb --- /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 0000000..c11f0ac --- /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 0000000..b29ba17 --- /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 0000000..8f3bfef --- /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 0000000..ef97b2a --- /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 0000000..93d019b --- /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 0000000..3126059 --- /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 0000000..b9bc104 --- /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 0000000..f13ecb2 --- /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 0000000..810fc6b --- /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 0000000..c16e1eb --- /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 zcmXtfWl$X7)Ai!E1PM-XcMt9!++hQO#TSPV+}$m>yE{t)!6mpaPJ#z_f)jZ6_kZj8 zu(efFH8pqc?e25>oStY+bwzAUGE4vffUT?~rwsrgu>bc%M}=SU_D}c%|3mn!ttbPi zpL%x)08j&z<)n4JKArlbWm?I%-gM`c`?|g1rXL-7lBVBzJ1Q)uOOH{E&YFeB>Pn9( zhD@)dP#jZ?hzg7%;famGL`0pN;4WhNa_9ci^OfzSy6ZUiv-8H9$+drg&}WTWomRWk zR)f=Oz|Qmy@u!}%wy}%89VT@lWLCA0{DOiFYjdl=-e@8Ofuml?Uzr|i9!+;8wpR|e z(lC23xB<7OL!ZeT3a))dIbg?_i4}r?e?gBH%%{pGv=U;S>dViBBoSK|J#(H~4JG`* z0Ex@KnQ6yEoP$4u`-E?zI=;Rvk6oTD#Z8ZYnqO^(h~f`_PE>hPD?=O@7|_J1R}quG z_!qJhHd|JJ=Slt8Rw+6&ERRjrWgt@B@)Sc+AfkSpcy&i{Kz=|$Nd^)!4h{tV#SZ9j z?7wGvBXV2#>hh!WGLy^L0~YxGd+YkFM8I?UAvyJZDYhlJZG(|iJFYyiVTQHW;T!-_ZB0*uSVAdcE%#G zX7x6n6Hc}%P{ST^s77Y?Ra`|LC0QNr>Q4PJvLhs1FEGx1$NmN|&u_GC9^F)^=U$e5 z`?mOd_brC{AU!&L%UC&m7#KX0*pu&SxMyj&dZQg74+tu{873HzaalkQiU>&k_gR`z z5%-bR1om{CP6unrOnr`6$<5NaLO4r+D-TR5b9-m;m?8 z1`d+{KW-r*rD2zEhO?FJ7qagt{CTj43!9pPW*q?}1(BpOmixn0`Jw}?aj9!XEf~=U zw#%MS2tmZo+ew%RpGv%~7iT=ps1&n2r2>zFrdfP)u$zfmi|!}>m#MvJ3e$$#ol*=v zom8vSrS~90H8tl?ox!CZ3L#<`IB6R>IoX9)#%xe1w0CgyZPDDXR0;5${TVpGYN(Ec|(X7+>v~8@Bo})EcgiX3^2wu>$$?M>170 z5E;}g>|@cRE`K(L#!%$Z(QXstqovQEw^JBfE_)E_5Q*yG{hr_bm5_+ToTAB$)E6gt zkG`0#m^G?*)BoHe=6-=-mcQxl__3Wbjb9oaJWu5Il(VKIq<){eqO;j!9}1(MvpFr2 zj=N878fp0c6 zy^ia?pQ(T(#5ULMojPi|x^_x;Thluqh>%~8KmAU#Dxk;9ZeS?Wptcft7o7NJNsqyh2X|6p4E0z~zFKAuu99OiQwDItaiFJEwR%G!WO$ z2Y$@Ws$$+)TYKuIp%U2+BWQFIIbV&pnJW0_#w)e{d`I4RVd_l%4#vO3bpG|{2}a9@ zulBb(WvI#yz)~gKK@^G^r4Tw36)~!kRmMUdpzTxJ{K${s%}t%0nclySmLI?p>k~ki zawA}2Y1X=oRr|0N64&?&GleQ?(9wk!Ni1Q|Uqd$0kNHyb&`iHvbpSIp?9$l#tc_+a zP=H5(#k&+oS4_0deOUM7LI-3>D%m5AAm`+6<#;l@{dihMaw5BX;Uk6XvsNHZV%yUg zm)IH)_<5=?eB*iPxbb4c*=19H%AC9T@2Wgua;Iugb9WGUue^m)b+431X3v{{n?53Y z*}&q_oCvcY5}4T14f&k&t%G)oK2_y=A|L z@*O324WMz*At)7<0gXU{`zODvgqjL}w{Tbr(9CVNeAfdnmBgZBL^6{U5yAF0;IOcY zS!oQlGNqL@8Ih*@J?GbEDjN&T2vT`!x%Ud?_WD9@hreYyq{CELz{7#W1@Dn~vFP~1 z+>Lx!(3*iWUZU2gp&H2TfdyeAeJvB+pqhpGW81VoJBdUtvef%Eyfir;KPt#44Hk!S!k9xzee=BP}ykbIsgL_`jxbWwgCRm>vmd)PumhuQJ@pmbp^X4EN z^B9v7Va?#0vGq5SY?IYNz!NM}c593P&H*=6W+bV~IVu}!9&3Pqryf!vfiKYREiOq) zK%3f$_NEj-4s*oac-pKl#Y8nLJ8g?h#N#nLeM6w9@QQZ%d=)tfVVQBw;f-2a`7rC< zr!Q-!#68_EH4M(|u0$&f-%@hh+2yqAXQGVc4hacROR+UeJ>G!Wm{(}j-fAPx z5bWiyp-S^GBk%v=D&>}$bTY3nXTGHK276aBC!(Gphd?mp_T5bqbGxJ!l zNCP7H{YYZYxPxR7eN>TBghZpr_Ol}o$$X&HS+k3L{r>GLJl2^c;9>f8q&_2*w5Zr8 z;n>UrwPKu^kFN?6eY)!p`o==M6>P{Z!>!5#g<|7w3tdm|X=--oF<8zwM|^OI#3!K% zgW(XTk_*qn3S`h(Y6vhDe*fxC(k^z$m9{80pw?yIgss}v#22~!93{r)3bZFBt4W<~ zpqL~)9A%?2~zQajR)sL$j_PB?*izTti#Hy|5dGWpRmznDm`gRAQ5npHDVvdug7NOVV0cb#_m5Snujfwzg{vd6SCbuat z70w{uSa78+iupHnW`iv+3gJ4g25BfFCMr85m^VVa4`uDU><-iGHLo9e6jt~PaF28{4I~J5e_%+AB1X1kUe(DAKBaMI2qx;$nd2;-c z?jWQ9OePX*wOC8_G2-~v6ud7#Dz-&4qz{$q?7dM@tq3>5pL`3_fhX3*$cAPu0e71gMt^be+Tve0sB*%B=szY_pmt{X&8xjd8qKMvo-xVkq^i%KBl$=`}UpEz?_AQey_p% z0eGAnM1%(l`9KoURo5%5KwlL&i_7Ia!-Wn8mVuU|y3W@XmW{N=XoUD-Xabnnt`jzZ zW`4%Ofc>x-hsm+$AOs2BDT%)p$#s1ZZ88s7bUSXvBA5FY5_QQ{W@8J`RPEj}9!`_E zn4!?w3I$s|K!FU{XCH(@W;7MeGZyv%GbBEm+}O=&O@6q%GhTmd;q2zY-HSf z!8Yip+gT&=uA?g+ew1#Owz_(#@yrPTDM*B^EvpL?Mk*)wjcEd!b3`g z6r_*@h9CAFeAX^=WuTv?gkri=Lj|inG>O|d+k$wsP5u6a6|?uj2gIwR z`yEk`zvml9_#K4BJ~R45s2~n25&@~)$omY z=>ofSGpz2y)Huuxa<}4}S_%=+CDI}nSwWKE1e5h50zR<0GDjc>!#DlIlv z2f=GbA2#l(t8dm;D^Zi(l-%CmomjUhP-&k8IM}Y#9N%*5mpOeU4gF^nx3=cPl5pa#QiWxpjXBo${I^l9k*49V=L72x<2 z)R;6Kzh!rf48B8m5DXqi%JQKmL2iFgS__Ui!^iB<8|QIgonjPx<-BDWMUiDE@yR|U z0L)>UJQXH@Rl`xpXO}MI_M#eVwDj^|QuE)xvWYUX@KP74Ta~L*@Bg`yhl%etIdbHo z=pyROnGjwv5?HI{5QrYTZph9rCix5E1;yBdR)j2;=}CZ7OE5Bwp+Ekh^4e5*U2w4< zdx6MJvajdx*P%Bqlg0W`qdMFigxyJSoWrzrE!{=v<=w!&Y1*DxOdp0wrJ1%bgA}1B z4!y_n2R~g^IQ}Uwz0p?CId+oB{=nE8nBG|^?;QDZ)_m?)6MHt~ zpiB^KhadlpW2}%)UZ_x{fi;c4nwchGf8Y;tbKwL6;3cKqlu}{u7*cfeo*@~O^zE-5 zzN#7)_=x3KcJ5h9GQqx}{U=jBQLqAN z$WosFn#^;yNrh&NQ|wza+cyX0x@u9c{~hy6l$g~1CahYHOn>`@+5y-uz;W^zBfW-j zz>86eIKb0LP-S*>C$2g-t9vD_rtaLAJn8YnJm3Ka46C-&aoE_xz+=r~GdFx{Z&i=e(#GVL_))l4H#_Du^_;;17Qf zMPadNT7rITQc|<9E@VSC9^B-iYlWCRJCyVphQ$G3<+IO>2pTdcyv5bVK&bN?0r39y z8}J@|`&RN;$Me^$eADkKT|%vbUtPn=$$w^{ZAL>%sDdBj4ySAFkZ&{d31H#qtby%h zUHw$%h)Ja!msJcy<+j?}ZFPbL=@X6I)Xj_tO}KMHoJ#cEh`DzV?*jcOL0%cM&+c$g zsk`8bNF8%Y!&M)3E0#)EY#HOH&<#~O5A_=!Gm6YjS#)2zi~*GX%GNicDWJtp9c|Md zlG}E;#Whl~2gvf4cN)oI*+`KRKTz-D^BG%i)r#*TCM*}V12Mr0)>f>XHcn@_CZ@=#nT|Kv330-m9#8{vvJ?4$HC+ z0&E)`9l7sf{_Du5MxgHGVW`u3&BA7h^MJ3s#^8MRaBCV7v z1(UWle;&#B2!jJtEI~M#^6Bsn)qL$CNoG<*MVYe?SZ#^ipUB6y%3sPq;Nk`<^Xh@F z3rSogmgHkjO0bN$+<`~_HVRF7>3byK*Cn5}JIE-XyVI=6eD4?{9B}b(7fMqo6|@Ab z4KV>=PQ>{_YqEIc!o!`kWC6>~%AvPo6YyH5Ke$}CA&nv2rGcffreppCR~n8eBrFjh z7nm4QNTY%flJ6j8`0@*R7wpy%spi&J2-x;Gq=zc9-`3d86TI5V%p-_N(;F%nSQKVW z%^7Qy)QxJkR}`C`Kmu%718D;6GNIcL&|bg?#}U?U7wz_W!@hYk`z^_sG{x9jad%Y4 zI+GH?I%T3+T8|={x2x{;6UyFOcxcRyubf6Yyna+AeEB2#nSuH;`iwAM-RRl8CyT=W zKy)ODFH3vjl{a`K%N4$2{%{P`xyOcEY7H>F0K=D4EQdMG@CcnS>K|-p7tZX`P~i|A zKrd}^u2k(302^j6=lqkMY>wEzHGib!16@&rLI$`i_gtrZ3-o25pxZyERnv@hS{D$% zTmda`XfTF`&>uQA#_2<1X>0`YF4`&;>_H(zeMUYOGpX$D^zOquk5go{DnXS=S`G@1 zqY1n?g;=4;93L>;OQT{z&0lq3b3Ku?F+Urqc?FCIc!R^;(DKWzYGlFFBFRA+Z*5v$ z&W|C{ke+NFPR>v_lkEX3W7OoTdVm^?GG0!%Qo0D}rS|j6DDnfUG-@Rkm`e0= zxYwqX|HM#k$sAmc{{t4-*VRYOJk9KLChp1+6AO?;Jy`!nQDY$ zM#MQ3#TyCRV9yGA*0NQSXz?&PP{vQRnpe$nPI;ii$(YKAu{p|f*oVbh(#wSwXOk<$-rRWbOV#q=%hoS#-at!}G#v_eMRPJxxid3pO8E_x zI1ON|qOk{e;<_>-vx&+g+ka+eozRjkNM+@UrVCS**zGjlqjg-n-bQfmWFRtU_bgCa z9N#i3_R7l0aLn_`Dd{TNKA9k&WwI<+NJB1L4C{i;8889L0OH7$tRdZZGxNhdh`2_V zlP~6mD_J}{|6y^kHYvd9I7%q#ATAC;DE*e$RfmNf{HbKbOjDuo^E~wL_kpT>64El= zRa{#wfVH)uH$X`-lbuN6Vw_S2zlaW{e(YLB#XrUl6tFQ+X_^dF!3vpsu^siWG1yxr z0LF~~iRXJ`xJIx~Cq`tTZ!?<604ezUR>gMTCHbQ?YTOq4n9xKcXy0g}(k=rqe|sby zrrJon!SI?m&RmDqwnheQ$o8Crhu-0aR^r`r1TlUA2@~+^<}NOg*tR)V7_OMO(94lp zk4kXV)b*7^h$aXoMR#CQt&fS%foYWE^<1dX>Tt#7i~n zJmD3Q@;+j(Ot7L7V`ok@v;JlakQyGwFU7F)PK8`wfSwkq91eiZ!&O-V({Xw{V?2O@C@dibfF3ra%-`L*B@_nZFgX5(i zb=^NVZa6Vb5l}YOu$McA2&ZMRI1pIH+=?-Y;fZK(9>FQy>D7$mPm&jfYjZne(M1xI zu?ZzL71bJeY7WnJN8$~n(BVn*6Z*ah#nYk&kBSJb&}W8eN~%J^B=n+36oX*>xq7kx zK~A!Q?sG4u(?FZyHdAhqjJj_eGM zeEe8a+3;uf0SvtOFoiM6S&SI`0#GMQXOzakD;cY;jYT&J8d-u}JyGIk>+9&e?R3mh z+d36>LLztgL8F>2B76##m6dIDpT+k&~^VQFaA6>O@ceXJgGG?8Yjh4 z3ki7tKny`H15p2?q9->RQK|;Y?X1fB-{E2xvI}gM5c46Kj5Klwag}w(VR6Hw%2=DP zS=utTl~l!jhSbO5#J6pV@wlIc=^X;!{$#?cnu$@80#JI$ha~^$&-vD2LjcvHM?=}S zQPmzalZ)vmD7RQ$gocXY^9i#VO{8xD(E_~t4tMeam$S|l`+eCMsPkMSdLzDtj!u$3 zUhqdHy)VPLjY^;MP&K5|A%nvHACtvI_%)R*!@#dqVjo(3b9_}5dM~#Q7BY&j$%kR+ zi42!XTA*0^E4Fq!#ks$_=`*#Ec6YpXQ>B{Gf8CLws5wobeV898r}HzG(9v5eC@z0DB#YO!^`j9u6<5DmAs9k1ivXU+Ex*N0AFDe%T!GhRT6%XbF~r z7UM0o=96#C>>|JPLFi_#1w1ght`I_420%4MEcKoeucLQ~cVSv-im+!B#%i#9tf_{VrkqARKaf{Cu50&yY0{WX zCSb+G9?jAw7QuiFmFNh@B&iYIOwRGHO1ShoiDX_|S!qSgUIT6R=WvFP>QodaI$P-4 z-D-CBwn#pciv1hGDUO<1>^@1tL*un~qKQ!!SPxYR!-Vl{TTf{w`HCBV%y7h5q>sSm zWrc+gE{AO+{7F~R|ACh+@b?Bfr+;+=1=5rh%e#9tguMg@2%Ur~_I`>yRh)b~)q`f{ zF&vU(nW!H{Wvn(%SO}CVek^3!8;Wbv@%s9rVetqxBujsPP7|Spfj9dD^yZ(^+i6vU z;ScI6EMUj@xkYh=tNsh^rbPW>X z#TV8cpTmd1UIWQjvvTmVOz%lZ=MF=8j(EWT@D{wIyWyzVCdu%Fjd;F>o+VDxqH>I! zHgF*`H9lF7)zzEyFVdimNLkZ~|L7C6vWdtZUfK1RHQ9dXOTG3=MU7B(Z1OMLb-a!_ zS`>mL?*Ao!qzo~5`CIHgq+_n|IV$qwwW}8C9Zi<3^b$KQViGb8e@n^oI2|($xQM|9;j%O+~XLobAsW z1ni&+HKed|s|5N?>Zl?_x^zCe@ zu=;h^=(Z=#gr>i39GLPF#{)9e81GUZpM#QBt%hb|Pn2e2KA+o2V#9GH*_TcvSNZ|k zeZ48Tr6KuG)Ji7KZOs5gs(OedabYvd}$)F?#>Q+J-FOVkYm2;U0QIrk~O( zBN=oD(UNlh<)OMeq8=u2L~3X|Kvt2?PJ7Ig8f;d3>0}AI!hyWkTw#RlcX@$rJC9bz52894)XQHh--s zR~JfpS9@t^${3t`{hDv0FYD<0OT}tK`FECjXhy(^XiyVLwY~NZBHU_cl&vrR0M)c6 zyqgz3o(jKqHl(g2o=CHFv9r4FQ+|3q4ma2wFX+G#*7Q(MPnYMn)wDLP?7KHoBM+M= zi$Om#XZ~WmYQe=tr#JupUP_hxZTbwv!5)Y1QzS=#HMayu`T|117yoUMaz>IGi8^j_ zk_PO-)xG)H=`fCO{=R4VI%=r53m>T>GV*<;$3Ja#O+G=36QiDpn&sy`Ol5CtE?)T~ z(mFau5{bD4kV&V*>`=1RXdnIpd;8`Ux7_2n83A&-VsmRNS3>5Y)*Zj(ZgQaKe=b0W z$MLiU`n|QNs82f$^`7&us{!Of56?PSf4jFURsjv458c|Xw{yUSl6>DVpBnU>Pibt( zn^QMOREMWV{@%|0t3%xzs=bo>lxS{_o=@5m#_VS}&an%4SDXkx5#5ox)IQfb!)icJ z3q?OSt!_cO;THsz@2%gU{U`;mz=>SZaf~m_A43SVMNLR0?+=Fq(5C~Qm|XK}|4Ob` z5DFUb1Zf|Qkt|XwkfnQ*lM~VKL`uvalF`7S!VSBi`M(aSU!t%%8s$-j5TNs(8BQEFU=u7YGJ+sgZTTEwEm(+i2x7x)@Epgk8VxQuI^BkZW}GGZ zR@jm8W*dg-uP?KJ!Y+)*KdA800l05`Q#F@Vk!>V%Ndq_8aL>A)-;&#ykSAgQfRdJKw7vWe= zL9Hg@pd!hMg<>64cppYtKt!i9!qPf&%==#9&oM;6L%eQEAP-Wese_*RHzfgMXDj7n zLy)o#BdK882wTjxk9l{AZtv46-*i6?<)f35=B1>-3{;3_kNFBxWYmAe%?e*tjVS#p z^sHe_C!tpViBe&Lm0OpUZmAHn=X^l1&-cwb88U2uP-Ycjk`BLZEJNv z=m*uo@!LKi4{R0e$G;1sa8etl&S>W5HY5t1sFhrT_duWCb0clHU+?!l2yzCMmouJX zo5+0gF*EDCMm+{nb)Uu3ox5wKr=!=|dH(qP4U13ccf3_Qu4$TMU15KmdwI3#bX5?Q zHJ`Tk*B`zL^gkHiCY4vK7^T0n&=vVTFTxMlMAT2D26 zeC8((a_?H>(-1@H@!BSp8#h0x-l^7#2g4iGxKIfZ2UWMyI2DZdHPBmb93+)->0PZd zM?pCxC2L`F>jN7c7HquQ0j25)4g2;~>{8j@-IjM3FQHq7B{-kG9vwb5IEZy{wN|BR z*qfOFoO%uv4BOpN0$ri_m?=eco^|G{P3ShATS5*{#h4G2YFI0tT1fB2j7q*MGH@ph z4-tX{a3Ykv_sU3E&#* zeR+J^-)dUx4*8}11_dq~Rw$83RiTW07{P~Y_=~c6r0M1sl>=dbD*(Z88Ykksr`AK=77{|l+!n;1nPf~iJq6@=0PM?uMKnikAvQQzJMBPtk&JbU&E^PRqk#f1-|H4zNC~wPW z)#m*ZItyIN46S#xfznQ1z#zZ=YpBBu!0w*+PMDohlU`wjKnH_y-l5h z4&*&&A<1FbQ3XFHlJAv@MlV(VOZ+!Wn_a+%wH~ZU?E3OQez_5bsHidamc{c-%VfVC zKiFlsO|vpmh;^qRH}Qlm+Ma^{BCl64ocUH-Beft5%Wis7T~G>L?OK%7?UP?5VoAj` zeE-(LWNfqCQoeSvm{3%B2Vnpd>D#-66{e69dV7{;hr;1qDQURE%1VC-m)pXy&!$>=2Pes)D}Co6(h7; zC6i^)y`QMiTuTI^WR?@^njDb7(_9>y1u!VgA3F$9 zP4&FsyLXvo_3qMU=a3i*_=8@(A2u#71n$sln+%zic%eL-ux9p2*9>=68VVxmM!?(? z)XmW)$8$q=&Zf*y>8tZz-1v0pI)tFTtw5+;+Z_~Wg7tW*Y9Jj*cwT_HH^A5 zvfNE(1FW6?5ck{dOiYU>oV5@JD0JC^YV>Qv*0?$`9({dVw{Lw$EHba!V=LQ%D)UgX ziHe`o_*ai?-E|pOXprvmpI(4rFkA@I3T^V&M;@?qv=3ErYDX-e&QA`ARxsBjGadwF z#XEn-?_InJxH_d8nC^a{Qz??{1gl^njh$c%y_y!f`dE@{Y(jag^_yD8px>VqucasZ?BNl zLqF9krfJWgaV_KqM-mV>!8=(A4#kR&BCuM-`2QmB9A<71^yStF4|rUv5dd6oI=+pA zu)WgAH;3F5Ld4w%x-JTOUUX~$!q%)5BUjwbB5<#Pj3^pi=>d)@yopl%LJhCACvgr> zlyR=kt41O>MpXzO)FWCS0v!=-KDmDKYE`gS)&1(0nR@y!gWVYD>A$B>Lix^Z1r6Ge5G*HSz_Eko zl#etfzi|Lp|421qbHF*o;@8}8$wJr9Ih zh4B{SE0e-83e(jR7H@t26Yd+cOaR}V+UMN+$bx^U`JJyAyQ*@q6puU>=BfQz=+Q)l z)il0BwcNLjTr9j6zl2?;{c)qb^G@o8vcPzvg7lVYRww!xFvayHPQTOBX@WJ18jT(u zA2T+m+aomOecS#OS8wqvJCIA6kKJl8t}S_<23fAU&P7)0vaTcoafF}UYTscjQvP28 zk}M_aUN>DcW)zuJ0u7vfhW_T3#6pTtJ_VS)C>-}aEO7-D?&+00S23a99+-VwDf4#45CdGzO4SCMy zd`Gk~eNbL@Kb}-ss#l;x;zRf&c>cipe7Yi8VeV2I4L|wD#w!%#Rx4cv zR2l+fRZ_cOdIH7`E6O^n;$4SqD-y5-_GB9&_ESNO9@c1_;Jg)>xGSr zTj5vo0M!l_kLRcrr*i!1usuoi#3&_ZaSLpFuD4gQ{g>P$ho_J(=C1_^_@7E9;TcGV zx%xf9V;&0kug4oFGZ3U|vx)HeS7~Jw0vQ}CQu`SzOj8US6$G|R7R{VG$H4XdJdAKp zu67%rhdpQ!A=*6Zzm8hyGB;6Qt%D4n6EEEXZD-JNu;!{&MP?V6{|U;AJ)X*-J7(lv zKs11-zS*^M7f}%_a3al!xL}i3_1!N5F4WK&FCjR--ccY&23(*Ix$6W*wuL#g&O@Dq z_U#Tp?L*KeUJ*T%SEV(Yccey?YBfU=b>>F>jVzys;)qa_A~(YK}mMEaU&r7 z7l$speJ%&CiUih%|{{rnd99!S61{tB;)b{(oy4kw>%mq);PMJ7UvbWug^#fyT^t4nUF){0L zP6n;AA?LRZ87~+(rv8b+%VVY^Pf$6k^sz!rUeI?#-q)`TaHVcSc6BD;>g;_)Rs;fpH_2l5I_EMtF>mXyM&8eA_M@>RvMe4gwfcx$ z@!#SuQ_&RzKi=-kk^7Sv2osKyC2$s`*MhSE!-ZY+$>`|{Wqw9t`(j`RlF%Ed_GHXb zP%>k&6Jm-E470wq*;k^If47k*EB*VtOonXJ`NJI|f5(c> zn(oxSu;=odLQv-O=g&0ddb;CoRxY|Y4GZU7J@@#=mpxc!E-vWr^E(ir{H@k+u@0SB z`r9tH*{*zd{te$7Mz9BabP8#$Vm_21EBBNPHfj$M6qtR+D81P62SuFFlpK@GNK90; zidEVWo{daw>^G4a4G7qGm>p)D-DQB6?>7H!)ndJ5To%W>D%CZIjc0I%s!X-v>T@P~ z%&s`(#qU%%crnw(oU${CiLMzUsnu8(q|>Mzq$GZBO!;9>;IWOKxd>6_tD<2A_l(L0aIGK}6YK5~mSE0Xmkc1$PBT>$(!pMtj4f zUR{9VTqCA6p?8X^V@r%eP%T)TW3;c{ALml~;0M@>rykH4a#S{ojs>m#I{hf*@O?)# zOLILlk`N9tOZDN-f&VbPp2s(Wwt^iV&T2>b%!URbm$v+^DkVs|-V$|VS^bOz?t-lS zuBv44Mh2y~;AXit55FMoN3w|~e?wgz_NwSA&9Y;APXmsR@1l&eDHRY$-Zw_6&ZJqx z{);m)`^P#bOw7i+z z7X9t1A{3I#)v`}3s%;CN+p>r*)GaUd(;T({KZl@&wrGmRLpY6p%yB?(S<1q0 z*_#YD`fMXT9CkT0y98UnRw3dDHdCo$z}g6T?R=*4dT8M4KT4~abpSV8&;0Zv^E(bl z7{~0NAG~0YEq%95f|hS$l1_`y!tW-eVaFoNT&zerf}HZxVQ-se>?VAq)4~`H9;iww z1GS?t@Z;d2TbGDBx)LJkdxV4pAJD>=ACaf&Fx#{kl08kPM0&{G+eAJKD zlWSfQ4(&T@;Fj0#3=?Rl(;fpmO0Y8NIjz(o2c1|(Ex1C8KfoA`KhiAiAHY-syvK}~&s9FNePtS?4qLni2!1!;vWCiVH_fW^IL)5unF?>Blmdk8Qs5>Yy`_UC}v5nQK^!Dgtefi*BK!Mf3wV*v-VOK{b6Y^1oe4>B{2b4p%p?5!g4?+oMspu-oKZY2Q zrlU%_c2t}?z}iyI;RY(%wufpyz_%+faM^X*r1-^-S=8JOq|>|o=u18SdNkY<SVwldK{wRddKNM)lyV zujN?zY)I6?2u3NnQT)}jXE564Fe_fhx8WAn7^J|)5!ld3k%2h#Yy}};=6y%c+c#pj znl&zGm_qUi#H66RY)-F}7sV|b$$RBYM<`FZl6?l&^9>GA%D~jqP@2||p2DdTSylm0 zd#NxA&t@j7d0%(x`>S5Xdj<+g@UJkJDz)D1wd{{HGmVU`8o-udqjViZ5yLRBtDKL8 zcHc#KJIxOIJv0VlD3=I_Dd1~}Ny>)r5l`djVotnJNipDzYPtt-XY7ksdjymbcipqT z^An!Ks}^UDz*hbM&wa?(X7kMSW9M7QoTSv3 zKbyov=`2Cc8HaJc1)6~|l6;^g zZOaE;ccOu2>a>VNc^O4czM5+vkgz~)3W=^)*6cCN=kDn?!areo1DoOKqV~>cr(Sy%e3yJ9A(3lLV{crgSz@Ck z+G>=zVz22AVXUbP^P9s@P_yrJw_*P_0yWQGOu~Yz_wWJ z-%HfhZM0DMZSGnKL?l&QFO3V6$Ud!_n1)pJ+@#({!Fkp^T%AhE;flPyq$uNMdn;Q^ zV#BfaFZ!wbP2l6`+tIH*aK`&e}Gt$F7f(-t-fqdaKd1H|OKx)`x*ZQ>_ zK99zzo{elE=^{&lxrgACfje>YxG>*1i28IJn%GAb_)K1gZJ#FZAXbq66=z&7Q#YRW z-xEn?MXT4(k1mUZ1!3QNu6wFOWb2WzMY~yXPyvBY|E^tNHWC@WbW3@6%Y^DePo;L^ zpC%h}Y%=l!pC9DCb|ao9|C?TJ{)TZv2l+W`Y$W|PqD+QlPi$|!F~A=bYrq+tce^Yw z;nWULnN-v!boQG5;u%(E*C?5)aSV4K(Vn{QdbL~GK9pz&HkiX2IlJ6eFxw70?G>ZI z;GNVGNPMma)@-A0kW0*iAr*^wWw1X8p#_sQ@RRFOE4TM5!S_3&gemfrXh7D}9z!mC z=8?f~&thM;k)@Japs_h>IPV2cUJ5)jE(qeiql1!Bd^XdVOpLo)Bj3&D)rJ%S$PL^Z zLNwUS4TmzP+Aj0DBhvPF>3#IGj1>E$#}0PND~#UhuObWWy`>5hf3p&4i5~YcewoHt z!Q3KAdWd`Eopzw^&`nW`1}3SUKr1ux-tpDD(T?=KWlqVm{pGe|_^2GFt;X4`tVL$fw9jNP=P| z+$d{}&-8z9h4G})053;0?@q38Ol@rpS&O0;5g_Op&Zl6D2k1Q+(EwxF-99hr+Z_bs zQeMjoEsY%Rn$7tVbz!307b^ceGdw94Qbjwr_Fex1pXaBGgWsl2>pK6$FwEMybHq+# zO*RITn1_tw71T(H5LETRhPg>i31bU!wGm|j{$+|wUp4G!W4_P*{>G@DkG>?~LI;f} zH;8{>GvS*C)V(k1|Ma8bJMq-(Mf;YysfojIYz>v~BgRF&|B(fT&Vpp=AJmM%dqk)< zTg_^yyGaBerT?3yG7-#XH-S3*xYB0$x2jRc+C3L;>e=EyUEi_jdU;*?fbd$zNY=X* z-vJc!{n#H5g#mmip#wMToXWw9=ibNxf0ysJV*>x?B}klxUYz;gU63tC6K@WLl|g9c zh+RK5f6&rMSw<+NJc1C;HR^itbk&c!^P!s6D;aY=QF0!|Wv1Ql!^P*I8^IN73fsRk zwd;8Af=XrToAb{kxn9X7d~eINzs-C*-_oiG!1dU0PHc8Ey<6gWfMW90^6bIJ8o*7> zA@QfMlq@}d$`FuRa9Z#a6HcK^gGu7BiQ8DkR-GVm3nd3at&2g{eIiy#Nzn4G=oiUF ztWE#Ou2dIhO3X_&j?BHNv z^I~ViK--{En? z;$iPd(Q`V*)nvjar6cs*mu)X}&?M@p$(ebpHAADltv1_#JRE!Wx81tVY3CN7YVJLF zqq^L7|MUk*f4ev-Q4jA6mj0Z;=%hK_Se(u^(HIvOoh(3aG5dH0(c3t5n#4~Z%_O1W zNVc5MO7rU<`sjqYztLET+f9IwDkE6GHM*4REBp-igGqpF_`mJ@yo}d={};?z6vb|s?UqLxl+HiCNX4Y!tl zoX9psl#!Ig#f^?Ycd4p-*b)(>LZ?mkJiz_5M>02!l})qg5^$wTyWkZ%or`_G{3mbD zen}MInxBX{T6tdk^75;tCvvUf$4@?X>j4OveiraQx1+iOED)r@Hd71r7w6C!uyN|Z z+;fIXw2)uD8AoF7WJ~;|)$n?)*R~y}(u~YRbo76-06*KcYx7S(?{5!$SZCBAN=MwK zwZ|T}{WD}`D75f-ob1P`@4@DPdDQCG^^)ceEx+$tccj+)nezT)6COiJA^%&THa%y_ z+s_SJLx8>YSl?fBKUFr16aD72IT?P;%?88#1J4J3u(NZ0wcIIw1-;q^F#mo-ljr70WIdNCDl)@mu{#eQMt^srkN4?5fflJ_tLA%ODa%(k zaU;A4&4UO%2~wm90XBny_TfBm)|+Bfbq8#35=}~Lkj5R}6A~LePy_xA72XY4%c6=* zf+O1~%c6fRzfG}brk7M`-goiJbLu+VBY*GAcNsnbW7By@U~xt3ZCbz(2P!@SVkQ0} zYtBz6AE-tRtuE4t9jJB4Ui_^gF;Xv=L>v|a_^|Gq?*FN=A2ANN?}|10S%9HMys zK2CRtbW2F*(TyM|-N#YFNgiD)5?=w~Kw6ZRZaDhr=ID|<5K+1tBm{(K`8|IC%Luh-@U?2wcbU^5eLK3AyPd55SlA|0WsDWG8Qt|qWy0KzPM|C@V16sxO2oOGJ-*Le=8JZ8iK7jddUD%U z&)ia&meSi0N62VaPmP3eEL0*@ZzL9)R0^H#DhAe2i3X)bBsIzBB3Crcl@*DV}+X|`yevs1D4B6`2$F~zqvYT`3imV==Kyy z$}ZM#^6tqElFyOJ6DaP6VkVihsWGQegU*cC@~xkgj!HD&Hhv!8dXl^mX8T=+;$qRm z1C3Efn$ULib*t_3F-+m#Kh|yzDW#Gl^Fo1(7>tpdYNtsna?FY#DAf&ekq(?i!I5Re zrCKMi)r){ojT3&q@LTHeTznBPyg($-rL__yovN2tD_nFBJ`eG>yzOpf3C>o!5O-sW zrByOIl3MscUM>;#JP!+18uHaQobJoZ_w}VftsH_R8y&FOT84`wn1Y&Tj6|}*C1$1C zRJKJ!yO#yaCVnxQp>8eHKUbzx`Z8XDmcl5bM1)PgprpTR*;7|pJuDw@XkWsqudC%z z!!$~AVZntU0oOpMHMS!M+70wHUkcRZ<1?8nq@9CWyvN)?xW<_0_x#H_W0c`i9zo88 zpy4TV^r&lk_h#-?q(F_nv}gE_H7BjiwpG6)U{yX=azzhetf=A>#kkpbGr;aA`N3 zLKf41zdhax!OxTeDE2!p1Jn`ELHIT$mt~trO8+LsJbyi%>uM{lgIRpcftHelon_71 zy{vCja&~zsK4O`U-adzgGteZ-8>JQO9C67H%PC7MR)mYzz5eyl-=I_oPUfct7W}9{ z_M36M|1N!krB_`{I}rBjrE7t;!U%aPkUO_6~01 z_;Ah1Ne+2ga z0{OpXZ2UIz^c#K=w;nstKh$J(iR0RJwK1NloBE4idVy71dRNHEHCXE>Gu1NYu=h^{ z3oY-dwM=#?W0V6Lhr|(I_3hLv{GJaJ|F_p$T<8oo#ux<2Jc_I@Y9z)=elxpO6_UBs z&RSTRFOtZ`p zlj)ulOhF5dU#yf7_F>QOil{$665_Sie@_~nosYEv5&-jCki7;Weffn(=bjvnsD=3z z7`%Kx1LQ>gg8r(^`*gN1=atjOL<;l8Qy{k;SF%2A;HGrzW%=W6>Sg0sjA3G%Jkb=5 zV+ry{6862^za34Jb}j_KvPrv)7e~`&W$?a(ZJ`0LG?QUESC?XqU~TjxJEQ4xE$awP zBV3kiG>1sQp=-*E7x5b;-+H8YPq2!%tdO1*->#Gf!tn;r9V(-LPl^fLs^SDEHMnHe z-<`P2z9w+`x6WYuSk0fm+kh{MAvN*y5LBt@FVcCW`eksnBQY(m97wB%xt1}y4EC8J z0fi$5v=F0q}PlbVcfRZEzjGY2=`%jIXjs*S_V)C?oAHZ+%}zv1f0fP z$-v-hf2&JqH=QBx7Yfr!_%rn*BF6d+>;p}I5q>A4|GfLCl8u9_9r9ZLbE@gM^m2YT zrJZPrLk7FJU@g40oSazcl^s}G*GTb=Jciw0!*IM;vkyC5uUNn=|AyanZmtjY-~lQC zgO3gAojzF}_FF{K7Zdq8osHxU+UaCpWVvd=>hDSUuf5Z@S#-L_y9!7NexbYa+DNO9 zplQzG}xW!91M^URhnMzr-b%JN1-4yY<<1u3*Ktu~fo- zbGxM!pb)r~v4j=O1^Lh~JFz%p2N~kkt9X&k5_$NyOv3YbT{=nW=*?CcIst!PfL#HV zAmod!5Qc1GMV?KSk!mRSy>_CgvxPy7TdZ;Yrk7^ps^FIMUCYkkMRN-G;9__Q#j&7y zJ(a(IYi?;Np?lDBT=lwk{hcSCdi~*8v%9|LCL;bD-ZQiHIIlJ6_LUqj{ANd5@MD$H`8zGS<{C0%DFpE1j%GUc>$2-nH_JwHV3cV z6ahJBZ6qQ=$fF*=*~Zh2p2mms^JvujOy4rS5Od(Al_4dlSmhwDyv4M;R)Sx}VRB}X zWNzv2`}xhxpXk^#D|-^jC+m{Mev8@n$J|%z+~Xm+-MPov#d_b!z6=n*mb1-RU}s1k zx!Hg!fBTe=*n9Q1=dS^?K<**^UWSv#{3O7x7?2X*@bK0>mTl7|Ip?H$n!7dCB*5l6 zk^6C%^rPy>m9?>N5|oFxyR^{V8E>i5WfCUvme5H~4m)ZfPJu94*ovhm(8ov&L2K+- zf&;CYn2Itmwz4b%2^@ArJnhi5vg8|k%s`in6JxtrY+_1ME>bu3x?}em3BE*x6qctH zS~=~xHNyAYJ1P3>e&A)F{Hs zpH==Vqa)g;p<^Ej?d2Ym-GOGE_VGh#2tehJSxLsa;V(C2| zew<-_Cu`-aSXoGlPLq7$vo>VSc?VjUV(Ls5apLAc)rtzG)QSNZM3Z7giwz_^jrtwV zCe%jVjlTuv@qxANNApXf@CyY$rK>+1w4d(~p9w-vv1~kl*Apue>nZ$ICEVW9$sopb z(h!3`3p2^Mr-PG6UnA>>cfA$_g|SC4`0*YWz?Ejnyl=L|b0n2%L}#mw)mO?0f5e$$ zzPi2X`D$j5Gt#?lSsOTWR!8?$61AcgA=?V>iaIoznhaoV5)Rz0CGOcbleqW9Gn~A^ zx3|4J5;Ik;W{iXIc$&sBR5{>9Yt4!?rgTG<`mfI7VtgS556oNO zHS}}pY!li`#ABDK*(Oc2J(jYXJa821y5?-TgF`DfI@@V<;B>7pSPPf< zrMkm5eyV{XWcseQBWo~0Q^*uGU2C^E*cuQ&M(BaAeq;>>-PxaTXY29LAP^fbz9tT& zv&}3M*9A^GivZiY*s33)PN{aJUp_2(?^P_B zmh-HhL5^Xuk#LhY4t=-3?Qf9&Hv!OS4b@NH|J`m zJyGM-h9FLi!e*l_yYjOG z1{n-(N26z*@+XQ-d`N-YH2%!@jhCw5?zYvt<#?m4I|nk$gu}J?tCc47vG^ph{fE#( zxq-lSKk2)5%Bj;~gzgKbUiwI(%bla6h=@afjE=x1l!!c4no6A#b*h*>mv78oq|_Z1 zr=aYoa?Hi$CZfgjb745WNgQ0_u?A5}!-&uR$9IeP_qQ9w;dE%oDQwml#o*++_Hd$8&KxwCJ} zWAD*)5H?p_M%OoX5*%&mfSs}p&vE;$?$!&xcUc318{a zA`?AY<}h!%K4nW2qSHEcb3;t=Q2uArZ3BXC-k@dE;l#Jz{3%_pF1z}F{EYe;wU(XY?1aZpz0TN zwcwNQl08c>jQXB^$18vSi?t{Fzk4PUa`L`h>xmZ>XNMvWp^q&)mPN zqATpv8gy+;GUUyiHTh1$_EdnKZt{ntV0%HLta84 zFo><~Zej0u)1?Z)q|L09)*$F~l5bB*vF`k=c?Wo<$_+Yq zYy5z}e|q@K251Tnlo=Hli?%=9ZGpyoQnp<~MFjQ#s{B9fA3XzB{$(cBS7Uw0H`ezw zS^tKFb3p_XR8Br#0i)^il)zFEP6?@5)W+;ja_R(7OEq}dXqFH&7l_|&$z&t+eTQn6 zfiFnNGRR2Wu}%V`uPf+pK7AnIGk+fcn3=A6 zE_TZmLvV#9PJpPqEsQ-bKE&Ug)27)OlIWl&$~7HH*QZ!fa9?Hilk@@D`e0d4HNerz zXE*Y@W!a*7XJ>}c=<~01W|LAT4BEDP#>{AT*5tMc{AYBS6A4eK1I%~WA%?h^oNI5K z!a$3aSd>4x!*0yfFG2};9CflV#kh5({^KjnvCc}fnn6w#$|eWl$Ht_kNobEtA-^;k zM@W4fq$Room%&Xg)2St*YRGhEBfqaqKkjtNS;Oh^>1xx*ST&90k`7}50T4y^Oq_;D zuGJqfVagDu@~P^hVA^}8nL+DX=hFQfZ_LL(mFx5aIg~f;owk*6Q>lWJdQY)17|Hj> zAX3yn)7W#dB43A3)~o=DMBaTZx(0aYdeKQ4!z8gNzbRKTlLzApNpV|gwk;*DRIAhL zf|;19={aIdzc-g}QThqZ`MLm@PiH{S9+Shj9p=Tn62l6i*47d~TeOxNP&TRlK!$rY zn^~jYNAwuyVnVMN^q1Zgm#aQLp1;PSg7Rx(mXsTrcsRBB-w&GhmZau-(L>ey zu?!%7BL-gbq>T@#1(vjk5Q=l;3@w35e%2ie0fwuawFiXh5z0P=*24w%@dE9|rX2ns zU;S56S*SFnq-+6)ctuCZD@cn2?w|8Zbtz%eghA5dyq~3K5@KP!TusZ)Uu3a1%{5fJ zY-+9$8q?5)-jbMxO*`5$iFK&ujsrzLkY6o@8er!eJJ^HF>Cp^vc-hH|qHkgyoctbL zU*D)!lz(u-z{A7uQNX~ypP4xJY{3&Al*d)3a(&GZ+0=b+o?)SisgO1l9q|oUTrlF> z+QQwd?cIZryYBz!m>42s8%ZWJhf-rm($xi?avE*!te;8XjyfCC;WZei=hPQcb0LBA zX67$BGN}S8E1CEBR=3|G2x73ZlY6TZ;VbUfzmLEEXufB(sg#YIOqzK|wCj zPYxF{S;KX?j>W;SpLN*G$Kqj4@UVM9)jR`#@wld!wtLJ8Uqhc&qBHdBri9&RCD>4iuQ=(SIZiQg^7%4CWiWEa@EJRU|2d1>&C?~zS+jWO&gHWih{r{r=DWB4B zipHpun>eGZp9Pw;*$maEjHw#D1|ujm#xO`PiKc=1F4Y z^6%%xjfBl>q^-RIBl_L9RRMf+S5<=;*Uq*n;2O45X|mOJ zZBo?u#j#40i;|wQ(n}KJ*=oPneapg%IoqVAu~DIapF@im{8VwVXT0(2Cwa_T z?R?OWC&ubvANXd;gEOVwVuAhl`Y4dwB4XN~^v=j>!Lr4IM(ydEsrI|o6Cews3eS;8 zxdqVN^rQuSwfphVLp%i@z2u45)f`M~$+PsQSI2cp=@NFzXr4g^yf5x8k_nH7_S0!X zrL}FQJCEkiVTC08wqV(O!_xenqwvgqfzSOC4M?V=AanWtNAYwtEVO2~u9T(WC%Vyl zcm(f|=++9-bw*d6S%zto#xbsE8A7hkJL(~kI4Pf8s=jP8~CUJ`!)f4EPRcr~%+NEX&Kx_y# zbK{1E_7Yo+4v5i2+Sd9dPk4p>BrDb-_hlgyvwZ#89AOsT`tv0*8$lQEZ41CTIkUhz ztCTmeTUoe19sX&nU|JDh{Z{pR{(+odyl3btP7>w!VvzDhsif0{RK;W|(FB?EqCjG0 z`zaLPv1p=1>Ay_^&;!i8^!3a#kX<&-nCNDijhwvap-SVf-J<<@Ft;Ultut?$Tx2o= z7*NRfy;IvdAKO}*GD<<_;yZ!iKj9UkH()RPJ~G{uO2PO+P?nifRwT#Q%_dRD#JN(e zu`lvf;zS;R-u--u*5stf2ID{?;eB(+CI+yBHgkeR zM+GnaDz2ZBD6#(CVh1~^uiANMI!^+T2ka)F(boi^SQ5Flw$;7#$3Ub%c$yeK0)c$D zoa$E622GJh=!}dDPoT;c0($y2f3Dcx4kD>j?w)ywfK0J%o-0;ZrY9%Nlx6@n8LZcK z;F%aFjK^6;CLH~0?bsJ|_Off`#(L!|5(49zpO}{1?R1AwU4aUMp^3{>+^N2fdTDGw zEO$<214qgXM!X#?!)(!0MkC|wXjDf81F%cJL^XNxp2&0!2wfaa^I;GmafD$~pLxGR ztsddyzyg8hgAL_`L($^VCAn%{I^>^&Ai!D-C-1W=iNH!_tQX;ffTNM&3RzGeqA5IS zdMJ&Ru&~Ee`*l*}X{2iUk89TxV7AzNbG--@tiFB@{g11|M+e2FSg2H zk=Hudi>?38(`~tceqx5|iEZWV@zLPJ;HAV5_TWey>(>DzbsQ>Sij!b%e!c$iLO`Vc6Qv>{*}B6`8l zXHfcs?#E?yDOMedVP8jt$wlBf8;dht4z>_|Fp!Go_%BVdVm_jfjM@aZE>Oud+hlU> zy;Z4b{4kJB28f;`kYV^#8F3s|>toTf=o}xnJA;?g!=1e^n8)V-3y-ZmVk?D~a%4yK z_P9l=oam2T&tiTAnpd-}j)c-VHa4&-yr@2I>h(9@T~N+Y8r4h_NQmsH2cXeRHZ(HI zjzWx-zd1Z$)HM6ejrNgheEGFPsKjcJLPdgB-whtueP>KOU^Ox3?_c$!zEtpafN=E} zu40e%j!LWo!ivD~aN$x)^`{^gc)_p8I}3TO5{*i#r!juHknfrrl0y`}`bT`fQ^obP zpUc03W`y}9`u>@lCXx`4D4ON{^07!QL?qm=aw~pw4JeJ*E#Hz1dFwQFa&;5j4|KvY z2Pr9aF1q-<6SB&%vnSh&c>ZEMA+Vvqb`qbqI*7M~#9In_sGK#<{S_sK#)7Rc` z;;A|u;&|L_niR9NLswTloX9*EX*gmgwr?c&A)UXT*MG#pn!yZ4gD;2AT>!A&yCpw{ zk4eAhBY+ktoT~2Xi-NVE}vS zXk`vGF{A(*Gwu16;$^Mf*tKcCVMGjDCC!w7HVy7GilZ2A_<{%mg^D%qD-`e73_p2j zWJ?UaoGQ9KP4w#h#d;b;or+u=ADx1=`tj(buE^+0OmPQx_A+&UNqm>^zqu`AaihWKxAYh z9AJTr2ersi3@lJfMh;hHCvnYY$JzPEfn?bWpT0)_}{JwX7)$5B~}` zvWFL5VC?=U^WVF<{bmL*;JQ1$Vc8rsN|!T`T(;d)F``T~F{wq!Tywsdj`I8kI3v>= z1-RS%GKcZs#)>1fu|+V$h@X^Lb)BYHDwgLBv?BfTnKOVY>!4=(G+g8nh|JHZUHNSZ z+gx3lP`V$NAS~JbW@cZy- zkX0}vt`j)9x{VAFJzbpip1^iUv~Fu9=&F#Qugtm5VA3k+&bip=hW?a91YiDgik27s z^31zut$15_Q=d)=H5AHi?v+_sIqa?yB?QkiWk^PEkfTo$UrbNY(KLJtD`hVQdbf{) zWT~+2Z!=Nd3+BJb9?jfb2Wpts&WDmz^pwEt7$40&i|=^f+_d2yEIcoOv&~H1lA1&I zyz=nj*H)Sxq3YX2GECIO$$kvmU>kox}h@ z!oDpuMT<(705l1Z2a~>~El^8O=+!G*&ILRejeL~ze+Z2(O+{@3)kG$$jQ(b4lC!YP z%{=xI;F>O5^~-no52#_!fQW~p8Xj`zQ~=Lm`)bIWQM)eh8uK^qnTW`qdL%jv@DkUb z!_GiIGzEQ>N;1K-$A{49v}}1TT&*=2WF;^HT#0zee-{p3i}NE;gucr!lq=ucY;^CSJ+agrr6r0qhYf)^9JVX zIk4D;dU*b5aZ++5EXsKyzfVv)T#w>pu@XjqTJry>YG|X%o0z8x{21wcOQlRTBi*QS z{5h2uMvO5Y&Kzt23zW*i=dMwfmuErn{J$)3@`tpy2~j4s3m6Y0S4Dp3ay3AON<$^g zqsx1E>z)&^F4X_py<>s=S|VLDAILP3@lH{Vki~CR-J!%5F0*Z=# zm+EH$XgaAY(XK|{ntV4S%CO7wG9QE7-WZq@FMxipFuCPBZd9dft>dwIVMj8*No zuC(6GOFB+9w4?s5D0!!WPZMQ>{j$N-o-L9oqhFqK2AYHF*iGIS^aI~RfY@8$kCo7o zFJDm)_$yR|ncVD;cnjgVU+`EF{<2lSBh6xPAQ)}0Os3r?td&ng$32+E8G6WxnyxL~5 z%peQf)I6Ie4?5BcP!nXim+{_G#RrpLN|KY1YjSX5R5lbjIF%#A?#HJd?6THRjRw+> zCs)$&Y-KaWvd%zs0f0nQ6LY;MGussYWpM?Z#jxIisaQTZ0B}#da4F(l|YrknfQ|;owaKYOv{s-C@Ii zhW_WD%Qii$sx9gvA2x~WaB*+&n1l51z>wy0a3Pi$m-a@*JFoYFXzpdte3H``4gEFn zIU?c_*(zP`SHb~|#WuNtOH?l)3n&51rr{s0{+UvPimG`=C1exzMYoDcKjH=kx{Fu>^*Xi$Cx*_uO_R2<7 zr9=nCbKEYW8qAc`dn(lKi18OYg9-du3*3rvoA!QhH(w6yHLiqPOv=GHevhxLU#k6% zZ_7Hk?m162d;TvokJ2dC?ACI)+bt+u0i>He!lQL*cjY4a(=XNb@PG`N z=$EEKI=gOaTGFd);Rq_Lcr6|em5rg&r7y`MfBA?SsorAJ*OXwvsl@u^d}k`R_hcxN ziL{-IyEHp&3|~E8DDQ5Uq`+S%_ zp?YsPM*jSrMW=>uX0qmpVAY5FvAXOKM{%@W?7{EOGdApYk$I-x2&j%QN>5|PIV>6ai-MVwiZ^NkJ~ z4iDLRA_^5IElSeO?mDAqq}Lw1lYH{;v)-`qjWD+8PeLM23>HRjZHQ>qcRbEj|Kx-!SW|6YY3Yt|lEanS*>MwKBRlP| f_Uia?kAHT>$=L|;ECD|GgQ20O`@B-cCiMRR$h&ju literal 0 HcmV?d00001 diff --git a/odoo_repository/utils/__init__.py b/odoo_repository/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/odoo_repository/utils/github.py b/odoo_repository/utils/github.py new file mode 100644 index 0000000..5b8d4c1 --- /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 0000000..c764127 --- /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 0000000..98d3d00 --- /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 0000000..4610d14 --- /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 0000000..e3e17fe --- /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 0000000..6e70ce8 --- /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 0000000..4405c43 --- /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 0000000..cf45de1 --- /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 0000000..e2365dc --- /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 0000000..88144f8 --- /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 0000000..4024766 --- /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 0000000..c1c8a41 --- /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 0000000..8318428 --- /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 0000000..7e273c2 --- /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 0000000..258a9f7 --- /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 0000000..25427fc --- /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 0000000..37f9351 --- /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 8c002b786d2e835f88063526b936c9ab0ef02b28 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/134] 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 f147fea..647fecc 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 227feeb..0000000 --- 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 bf678b7..90199a0 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 efb1e5c..af6a073 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 c11f0ac..b4c1e3b 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 93d019b..054f06c 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 0000000..ebf64cf --- /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 5b8d4c1..1b52e96 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 0000000..7d90839 --- /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 f4703543f38656324fa9794e6d51c361713c46c3 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/134] 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 647fecc..7f75f49 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 0000000..2feb5d6 --- /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 0000000..7a08f3b --- /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 0000000..6906a63 --- /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 90199a0..ca242fa 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 e602a638ecb4c60b319fd9964c20ad34403de8cb 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/134] 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 e2365dc..5fddec0 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -99,6 +99,7 @@ + From cefc7e3d8fb6d13c8c037f8f6b9aa1c0634c45ef 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/134] 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 0000000..ca3c76e --- /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 7f75f49..8236c52 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 01d3fe5..89799e2 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 7d107d6..33a14e5 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 dbbbce9..ac64b0c 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 4b6202d..bd8ce0d 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 07208b9..1bfb912 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 ca242fa..ae99a35 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 918f94c..d2eeb8e 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 65d23cd..08e385a 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 fd85f8d..d605686 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 f57d0cc..2367369 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 f2a06bb..7145ca3 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 b4c1e3b..1d2631c 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 b29ba17..a957208 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 8f3bfef..908a3a1 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 ef97b2a..2714025 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 054f06c..4df6084 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 3126059..7c8d192 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 b9bc104..2bf728b 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 f13ecb2..38605aa 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 810fc6b..1061133 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 0000000..a0c91e3 --- /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 0000000..1cfa8cc --- /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 zcmV;}6esJ6P)EX>4Tx04R}tkv&MmP!xqvQ^is$4t7v+$WWauh>AFB6^c+H)C#RSn7s54nlvOS zE{=k0!NH%!s)LKOt`4q(Aov5~>f)s6A|>9J6k5di;PO7sd*^W9eSpxYFwN>32Q=L_ z)5(OG&8><(uLvVVH3(5=mN6$uDfq6hdj$A?7w1|2_x@bHYTjZ%KqQ`JhG`RT5KnK~ z2Iqa^C@aY-@j3CBNevP|a$WKGjdRgufoDd|OnRO;N-P#TSm|I^GBx5U;+U%GlrLmG zRyl8R)+#mD>XW}PlGj(3xlT2VBo?s*2_h8KP(}qd;Mh|k8OPdt=00006VoOIv0RI600RN!9r;`8x010qNS#tmY z4c7nw4c7reD4Tcy000McNliru=L!f4E*r#T^uhoD64gmWK~#9!?VW3MR8^M8|NB%` zc!Y;7$mn7~P)MX#<0}Cnp%q(1l1gy(wAea|&fBqo|BsN|bMD!{?7h#v z=RxSZqQ$uj#)Lxlu@D#|fH9K!Tp|nuVHg<001N{_0FnSCM}DsvKpOxC&;UXM7!Ay5 z5JaCy;2bdBzz1M_0K(f0 z+s=~8HGBF3YzYh2T{-UYcF9>R=xQ)B0Gw%%t~G$MlL@bo&9>G5(wco1SO$NWf-Fx4 z5zk>l4gu$zquacXK@|dW%Zkdf>O}EorU%QL?V1R}Z@_dN2%}8?I)?z-q52dL&zzeaawPwnh!=t}B|)QkH3((R#LouHgDn>nmh1eGje=nO!osCjdP4|y6_*G1SYQG0 zP50RwlfPI3(1TXki&{qrcz8(jImN!$e4#!IR>3UK7}$6t0n;odbk#1{Y&WU=TqKl1 z<@DT2HvYhZDkho?O{*=)_RQ@w9?LgS-MbNCNDd_`h>T9=~4J;~jx}RX+AB%*9v4JZlV0oSH#{ev` zY-f07PDx20T2o*9C&Po~b-Eu0aKB|U)5OIoQ&Q4oO?~a_h6T&(bQJ;gh-EIb!9`b2 zO-(;sQ&;Y9;EX)y$0Xlm1#=|Mo{Zc|sjLRgPyenEwo5pk*IZ>nK59 zwW6|Yr*g0gW_iYtjd#N;ekMv>{6-Q_T3H@E6wPCzs=WKA&+kt*-eN(OKw^v#l3w)A z@eWW1R#S4)Y5?h$H4;u2<4AL>A4kv&$29&J-TdpFzxaT6gi+j8|6MDYaUln{T zW?<#dT9hhm@_%8#-8At8s;6G;mGnmK&rW+An!eSO3(Fhl((5xj*p8V{mX0FIahtN}JjWj%33-TuMCY|nfq{twGT z>Epcsz?xM(B&2v2z*wUu@*o=D)gf?QdMplU4_HveKCwThmE~o-Lo(@N zW;|=uL>|odMdh&~HJH6nxWmTmQ1#Yxh07D3-!k|?1HMX|V0%h&)#^`r<*_2iGg?q7 z@Lb{YMBt&wd8^W3uaYDwbYHJRVZk-7a}cf;TT%#DNmX!lq@ZfFr2dxt*$e)uv;XY8 z`fH;*q43<#czCPMLQ_wT-&6T~KH>BEy6uJhRe|RUa$RjC>3JCE19yL$X1^VsZ1L-G zU>DDvo2)qo9)hB4LR5S_*3-+xsxRIg&mCZX%0OPLp;_?gj?SU)@a4Qt_iX^yB?45v zg()6@^$srg@4$G#IBfBJe`Rn(cXO}RlJO@m-p(8 z*D85o{b3N@V3e666VK}kEFxfzS-q7CPom0u`6~junCL-cfJN7zoUi`~tfE|3rdh9M zQyHG3ZM(OVf-`j3^!IamVD4#6tSUYKH@>uQb-iYqp6XFq;{|JM4pj)cb4n? zQ@~;&vdk6+V_8x#lb+hT?lS-zRQDq!=$ei~;S4JTnzvte$&Uk2dw&k&>XX4DFs536 z`)Ux62q(7G`SH93#QDt z@EBMG@XyR*Fe&AY7Peg`@qbg;g^2)26wLCR3t)&@2Ck^6XtRY+7kLiws>0^axHD_P zNFnTezGaHUC^;PW>cDGi^DNtWf*E5hb0(M^t*-*m6ejRv1;_#`;mQs#tUnB(URC=< zVypnuD9aQHIh)+8Y-T8Vv;g=l^W~5wW6Ur2|NjPU=a-y z01vawV9eQFRN;tupTgFM3lI&lFi1ez#bJf54-sH>=QCJ&Rtzf+-2T17=KBc%Nvc{u zwC%%Qg<+%TSw5dn`Go-t(RaZbI;81`MD!LzhYsykaZ=9@*KMJG%E7XdV@tYAK;iC= z#-<^I5^c4DQGidfw+>d=e5+EhgsuPJXjUs2&1*%|AHBD-5u{?U2(7AGZzpYVqId`$ z5`L-g*g6!0#oVN9N-X3VdL*qzfXi@zhr(m4NKFDn->Pa|N+iTuuxO!*#WH3h8mDkb z{!f6>psICdNAVJmDv^CAOLk9iwGC|s6SC^=+L2BJPoZ!Td~@7 z!9>cyBBlmGz#)ZySgElXG~T$6BND9-~E-PXNt&d>;rXvnxMlEaA$W)4 zF&$r!1aO|p{%gRS$H6Kt5AFrjecFk5%|-D{a1#+OmgG_Et?{Qw*rW?J^Mdx(|S6>0Fr*L^^=Z$0F2DLWCj>u&2gyXmguP7c%J40Kd zVtSqsaB12-P<;C;fsZqE94txLrg|U=G~b)!8QER9j0l*DI}P)5U84caRsHTl$Ze;9 zwW^}D9#FY^768zXnSb9?LmCmNc)}%EEQ7+`RuS!avV7fvQ@{#`NUBipwJ^`?T=eta z7*)l_jMI6jc)(SVAi;Hg%Li{nF zFvSC|!W{R#%y`rQj^Kd5vh;kMuvqhy!SXsiPXb(IfX=o_A*Y@ASJ2l=2&dDhFV{_F zAzugJP#*>9F@Hti-XG%U`w~b3YG_37`)aEO3-MJ1O^_3aVc(Xm%TW}cyuA`z2YdSSnXaDg;)taw}XrsaG zkObmCdUHH?Sa7L@i@_V7d^zw|GEeIYX8iTQr}vxs_V`<_^h{L}u*;GL)c>+eD#)*bs#-h?^w)U28Eez;(!dQ^)nM=ga zn7~d9;!R|;-@oF;HJ=(9s9e`*o1{k>_}9*C5ltelJDkin`KyBer7KtnFR6J2z*Q!3 z1nnT+Op<@UqN=RUNF7_8yI`!w_5v`w0Q8U5EKg^!{_L+Ty$ant$IRXnUNyoZ8{<6y z155yx159(7=ti&8y_qEiO11`eLfsATRFvz=WJw-P;s$_iJpn}VH^COW+)UaNxODYa%uD=L!6$k)WACBD z%VEFz3&0sBpV{vRu${o$Taz5GuKu6Zx@_28GW*uS?V9Z}K+FIz1Hh%wv5?yxxO5JI zJ3bru-4$E5Y|&y07661dmdbab>gXf=B>~hCV-FMdGN{fbWF0eo&n&)2sO|8vdw&|6 zh75u|=?o${n1}~UNvXt~0#FJIOaNnicW2wNfJs&f2m))*8?jWY!mK73CZ-qFi zAgU>Fv^~hrb&VEGyTKS?*&`wJbx3OIPi}sF9y}yT<^X81Y+`yHVZtw? zg-RE&!f=)CCZ<~=vnxv`of$K5b4f*MwH^=C?bw^@`nNyINJ(n~XqIIo6N>1rlFCxu zX8F3Fr&L{ETQg;R8i;7RWhoOe1$w|=87S8CnYtg;Ra0O4m#Hb~0{~33OlF3v0!sXq zrT6Ro+?bpIS5sfReQHX&0C0t6IspX;N7u!cFc|ztf{YkEh8oUD8k>%EE!La z`>TTY#OmQOJ+rd9zII1ON?Id8Gm$_$YRo?S1JPan%F>t~e-syEdviR$1oI{U11-BM z)lo3b^H&9)i~WQkPAbe=G=&Af1n^mweU<2I5Y6^i1a`%vvAEr7w_@wsnve`#3g9iv z#&K7zVEbk9f-2@Y*2&tn@13Se<1#i1(nbJCv+Nxg1&0p&?z&|!u8-@cUS$b&WIfkY zNSwujMpGl0^Lo3U%CuksKvDMX=Sm7~1n_f<16{3JW7qiRc&+PK9tuw_N9=B#H8OW& ztK<74Aj*V6&eSxqTEKk&X9NGfuAoqly=i+>HQaoso{8DcK0000;+m3`SaU~Cp{N8_)5MlEcO+ZZ+>z`! z?%jL;_ZfSO%h|-4xpd<6uHCy0e;+JTsT`-^IIw1@<}IHw zMv1=`r~;~l+KOdu*9tua#XN?dTwFftElhg3#d)fX-v6Dk)wSDCEF@M}PAtxk*NvsU zxs?+uxx| zmRLMFmswcV9xLhe>cU#Ww%<|?i{IT?=e*_5xgorWzH3Wmsi+H7(lw4|a|`K(+``GZ zh1JQ`+gH~XmM2#ij^^evTrD4C<#SW3r#2=o_`s`;Ue9Ykwk?4R?n!Qk2H5I}e%0{G@5kkC@K#BFK1NHd;-6zLwH9 zQ-_S@yG%{K2H9h(k#jx0hH64J(_w0@uG-L)>aLl&*P9k}9L+bFHhMQ9udurQ;STBE{&0tW_znFZ zzahcO306+T={J3XofGVwVCMuoC)hc`&Ixu-uydBMbAp`{>>Sro;ae5#95OCahMg0> zX2H%OW5U-h*f~b7Mn(lYC)hc`&LJBG`+$tl6FVo^5W&tNBh0G&vc%2_c8=#p1ncu% z5-Y`ZgUCʮ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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 0000000..2a85d00 --- /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 1b52e96..80c8b3d 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 c764127..e119ab6 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 98d3d00..dde5142 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 4610d14..08363c2 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 e3e17fe..f264218 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 6e70ce8..9eed126 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 4405c43..f15d58e 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 cf45de1..a75b4c0 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 5fddec0..8b8135f 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 88144f8..63eedb6 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 4024766..9cc0dc0 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 c1c8a41..95d8299 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 8318428..418f12f 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 7e273c2..b49cd63 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 258a9f7..2671193 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 25427fc..0a46814 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 7d90839..3f863a0 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 37f9351..951c6bb 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 a4b651444513bda8a53b469edf40f263e9c95230 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/134] 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 0650744..f7209b1 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 0000000..12a7e52 --- /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 0000000..78d2496 --- /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 89799e2..6c5e8ca 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 1d2631c..e2a8822 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 4df6084..9667ba2 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 ebf64cf..6eb02e4 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 3f863a0..592b3d2 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 8d4df271358ae2bc492e46b6861e26729ec009a1 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/134] 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 33a14e5..f8c20f8 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 9667ba2..5f15181 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 418f12f..aec63f4 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/134] 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 8b8135f..83f4794 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/134] 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 ae99a35..ea957c8 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 884262616b2ab50b1872107af382cea387cf0357 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/134] 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 ea957c8..f237e5b 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 f9036e87cce4096493a7911554711ed691cbae4f 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/134] 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 f237e5b..6cfd107 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 0338300c04ff4cfb3f6615556a64df8a9dee516c 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/134] 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 5f15181..caa09b9 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 39151f6da231e7b6bb250aa3f0a7d7afe2be5a74 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/134] 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 e2a8822..1bc5c5b 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 f7bc24d813ed17c54583fa351fb927381e17f7d5 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/134] 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 caa09b9..db1c445 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 613a3f2cad78e05f9dd9b43bc9ee00917ffaaf9b 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/134] 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 db1c445..5332458 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 502a6459de8959f894dc976fb57a348c352e0b23 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/134] 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 08e385a..a835ec0 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 5332458..f6a2d24 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 2bf728b..5a8e45f 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 f264218..203a3a6 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 b35dcb04dfdd9f495d8982ee115609f10d6e5d12 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/134] 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 203a3a6..6f7ce5d 100644 --- a/odoo_repository/views/odoo_branch.xml +++ b/odoo_repository/views/odoo_branch.xml @@ -27,8 +27,23 @@ - - + + + + + + + + + + + From 1da2b74fb3c7e6cef2e90bcf10484f259972c7a7 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/134] 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 6cfd107..3a1b7a8 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 8b0d6074835a8ab94884e0192a1ec9361632882a 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/134] 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 3a1b7a8..f2f8360 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 083522b1776f80c5ab6d696471120c42aaa4463e 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/134] 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 0000000..27580be --- /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 0000000..af2fca3 --- /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 0000000..cdd4a4c --- /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 ae4d4b7a5c0a19ecf62241b3d399c8669f47491f 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/134] 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 a835ec0..2d9c3ae 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 6bfa780a798c6e30e99fb550f11549ce55e6171c 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/134] 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 f6a2d24..f4f5543 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 8360f21fa803e4a79e75feecd482c4bf56253a6e 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/134] 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 f2f8360..025a011 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 27580be..9230c67 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 af2fca3..dc05f8b 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 0000000..fc8b7b3 --- /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 e119ab6..e20809a 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 22a314a56d647a13605562886581493e7f188d2d 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/134] 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 f4f5543..7012c7d 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 d014217f851249bbfcc6b13e5728cf35b1fd2020 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/134] 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 dc05f8b..816d327 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 cf5c2661d2ff1a3049ea4e5bf34ffbd9b5091eae 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/134] 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 025a011..e3fbe5e 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 cf4c2e3e85b2a72c588095fd354881184b3465f7 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/134] 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 816d327..59cfa2e 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 0000000..72a4a54 --- /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 4d443c911cf21c73246f506e383326bbdcdf2699 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/134] 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 e3fbe5e..fc47bca 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 d41d040f3f39c7cbdce8e998c2536a9bbbdc37c4 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/134] 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 1bc5c5b..124f8ea 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 908127f78db6d182fd773c94b5aba7433fd3bd53 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/134] 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 fc47bca..df3f7f7 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 fc8b7b3..ff45269 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 0d69b04ac7ae0ac62f9e87678381f98a0af6cc11 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/134] 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 af6a073..11b0876 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 124f8ea..dd7efd8 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 0000000..7c31cb1 --- /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 7012c7d..d2d96fe 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 c16e1eb..20c1bd9 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 83f4794..cf4c9a5 100644 --- a/odoo_repository/views/odoo_module_branch.xml +++ b/odoo_repository/views/odoo_module_branch.xml @@ -47,6 +47,15 @@
+ + + + + + + + + From 971a3446daee16f66878bfd6233b9e2b7f437dc6 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/134] 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 8236c52..565395a 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 0000000..382a76a --- /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 2d9c3ae..66b0f64 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 dd7efd8..6b9ef8a 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 7c31cb1..fd52c35 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 9230c67..266adba 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 72a4a54..1e1d749 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 0000000..612ea72 --- /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 0000000..e622e07 --- /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 27390e3588c6d8572a6bba5b9e54aa5d93657619 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/134] 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 6b9ef8a..76a4a58 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 fd52c35..07daab8 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 d2d96fe..d08e202 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 1e1d749..8f143c0 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 cf4c9a5..40eb309 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 e133fea226c7749d09f53862432d43a3f3454a27 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/134] 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 76a4a58..3bbb756 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 07daab8..3bc4e86 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 d08e202..180bb3b 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 5a8e45f..abe9145 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 266adba..50c9d56 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 0000000..0de24ed --- /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 1e2582a19e20f17c81548a3cc6d1988bee0285ca 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/134] 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 3bc4e86..534ee98 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 fae9c0232ddfa43a4149270cbb9ead5a9fff6b6d 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/134] 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 df3f7f7..80bf740 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 e10787322262cdf6b6fd03dbb9cc33afef8e781e 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/134] 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 abe9145..755cc7f 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 2554b79cdf9cf49871961d8d351092ef47907c6b 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/134] 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 180bb3b..b5265c3 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 aa4ad33fc0110275c2015ef83a833e8d0043d0e6 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/134] 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 80bf740..3ef3614 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 074ed58184b3e34c0083a590c7244bc2e6f83e8b 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/134] 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 565395a..5bcf071 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 3ef3614..1432dc2 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 11b0876..e32af68 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 0000000..754fa58 --- /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 b5265c3..c7d9bb3 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 0000000..e782982 --- /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 6eb02e4..7e1f20d 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 20c1bd9..4fb9fa0 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 cdd4a4c..0c1e3de 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 0000000..1afb538 --- /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 aec63f4..6939ecd 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 592b3d2..90fcb9a 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 27601f075ea4ab3fec0f437c87825d2617ba9239 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/134] 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 5bcf071..e80a15e 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 0000000..fdca6d0 --- /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 1432dc2..b9d388a 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 7145ca3..24c8ba9 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 4fb9fa0..c2c8595 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 e20809a..5c66ef2 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 a75b4c0..b7a2efd 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/134] 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 f8c20f8..98a0730 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 6ea96c6d2fe8790143bb013c8e8970d99b51be18 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/134] 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 ac64b0c..ee81648 100644 --- a/odoo_repository/data/odoo_repository_addons_path.xml +++ b/odoo_repository/data/odoo_repository_addons_path.xml @@ -36,4 +36,16 @@ + + . + + + + + . + + From 9df45a1947d275dbecb2bbf038db0032ddac7b33 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/134] 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 b9d388a..0ace267 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 3bbb756..413dded 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 40eb309..e5714a7 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 add4415468fba417f5f464fd57c5db5bd382c74b 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/134] 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 755cc7f..344f8ba 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 979f82fb9de1634bc076c0601623f74eda637ba1 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/134] 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 413dded..c9445fb 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 a37ac7476634b584ab206d16d1d586af98c1adec 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/134] 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 c7d9bb3..37ba7f8 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 6939ecd..4c5e5ee 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/134] 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 c9445fb..984671b 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 50c9d56..ce6ab00 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 0000000..a6462f1 --- /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 e5714a7..bca05a9 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 09ae8fc930cb9c14446cd4bd026849111359300f 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/134] 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 0ace267..f2f8bb4 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 0c1e3de..aea26f3 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 ff3569914b21d4a31d32b06ec9dd0adb0f5129e8 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/134] 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 37ba7f8..5bff416 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 e782982..c208251 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 7e1f20d..50376d7 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 ff45269..2aa4d95 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 90fcb9a..7ec9f74 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/134] 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 ce6ab00..44837e9 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 f734532660de5f582ca747804497a0ed6482ec85 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/134] 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 f2f8bb4..cc50654 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 b5462ca907c316dc3e2a6137936ae6802fb033a9 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/134] 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 984671b..45fe19b 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 bca05a9..dfa4118 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/134] 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 4c5e5ee..d7439f4 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/134] 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 cc50654..555a079 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 1cfbd88ec094b0023eafd725b1eefb16df6af3bf 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/134] 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 555a079..df1766a 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 d55d21a2952c27a0547b18723c41558f83aae770 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/134] 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 df1766a..1124961 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 6bf57f228d2ffb5283806ad50ede3d5851639cf2 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/134] 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 c2c8595..77c16d4 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 eeaf0890caef2f0c729be3b8373e1e35b09587ac 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/134] 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 1124961..aeda0d6 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 aea26f3..8769a43 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 2aa4d95..ae57900 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 21c221e0d21cc71ef80f5f8742f2f76da1478daf 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/134] 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 8f143c0..30e7f0f 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 0de24ed..4005102 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 7c2861fe34a55801ef2bd069dc3552449fde61e2 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/134] 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 aeda0d6..c9aaee9 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 561bd32e86025c3df733fe9b1fe0fe5ad259b771 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/134] 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 c9aaee9..44cd9e9 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 3ad4f8ce87126187b0ac30f55432cc3c779cefa7 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/134] 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 98a0730..99da5c9 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 ee81648..1971cca 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/134] 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 44cd9e9..6c422d4 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 608c533d8b0e0fe86d50e2dfacc623331f28c425 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/134] 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 6c422d4..a3256e9 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 7b8d24231639c6d4d3773696f8739e00e174a816 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/134] 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 a3256e9..6cd4b47 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 2aa74dac32741fcf04cc30f4882840749fcccabe 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/134] 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 dfa4118..036442b 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 2e3d40637e2a8ebbd7a247587dab448cb52f9473 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/134] 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 6cd4b47..ef14103 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 a2d2fa1f11cc649b557ed3a2a739d4d01216f743 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/134] 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 1bfb912..2129210 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 ef14103..4447e36 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 5bff416..f3c3914 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 344f8ba..f687189 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 8769a43..07405e1 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 ae57900..7a1fd33 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 db4ed6453322c9912dbe1a051aa70581be10ef72 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/134] 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 4447e36..161ed93 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 7eb7a6b73f055bb21eb9d4ee0d14544e3e17e9e7 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/134] 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 161ed93..41ba85e 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 1ad6b1211997be0430d12ab9c1137153b1160ea3 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/134] 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 f3c3914..ea35638 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 3367106cb1c7cea3833c6f4b67591b786200b37f 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/134] 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 41ba85e..1626cc5 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 07405e1..f799947 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 e8fb44210ace90bff6874cfb3457a4c2f99200ce 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/134] 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 e80a15e..40e2541 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 77c16d4..4fda44e 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 0000000..191d932 --- /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 1afb538..8f30b01 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 6f7ce5d..3085adc 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 7567894bf495fe2c1e01913690a4efd3e30d8127 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 10 Dec 2025 10:45:29 +0000 Subject: [PATCH 124/134] [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 67508b4..15001e4 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 5fcae1a..da2e0d1 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 bf9c4fd..e374e41 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 99eb32aeaa28dd1ffdc04467505e61e8cb6a5051 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/134] [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 da2e0d1..81c84c4 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 0000000..1c25716 --- /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 e248a24..ce14f9a 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 558963b..9298124 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 bb4d0c15b39153d3ba41a4b2f60c4bc46f9ae5a2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 10 Dec 2025 14:27:43 +0000 Subject: [PATCH 126/134] [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 15001e4..c1ff989 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 81c84c4..cb9c32e 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 e374e41..3084f37 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 1f548002ef517934217b48b6d77394c931350c81 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/134] [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 44f621c..57f1c52 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 604fdba21ae74b095a7c88733bdfacd998261786 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 11 Jan 2026 12:01:04 +0000 Subject: [PATCH 128/134] [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 c1ff989..ceca921 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 cb9c32e..8c3b25f 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 3084f37..55709e5 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 cecf35b2039403fa5630f2e4a638c7eb7b7245e4 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/134] 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 ceca921..f0901b7 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 8c3b25f..6643c6c 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 55709e5..e2892f1 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 cac60703fcf2d7df3a97c0de7cf60af0541c8a4b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 17 Jan 2026 12:45:07 +0000 Subject: [PATCH 130/134] [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 f0901b7..0695239 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 6643c6c..18bbdce 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 e2892f1..9daa823 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 b18b9babc3eba9c98eab8617b3a4a3f89872ab63 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/134] [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 ++- requirements.txt | 3 + 29 files changed, 1207 insertions(+), 1169 deletions(-) create mode 100644 odoo_repository/pyproject.toml create mode 100644 requirements.txt diff --git a/odoo_repository/data/ir_cron.xml b/odoo_repository/data/ir_cron.xml index 6c5e8ca..45d5b47 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 46196f6..23b6fab 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 fdca6d0..bf8ca9c 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 b65a0c5..d6cdfaa 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 1971cca..dffbecc 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 bd8ce0d..be44870 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 2129210..f38e67a 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 57f1c52..315b01c 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 c9e4a01..f0d0ec3 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 0000000..4231d0c --- /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 191d932..1d664ab 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 8f30b01..56e84df 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 a3afad3..af727c8 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 08363c2..7c6b6b7 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 806fdbf..62bbfdb 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 9eed126..2d7e781 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 f15d58e..8fe71e2 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 b7a2efd..b375f8a 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 afe4053..7f53949 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 63eedb6..36bf39d 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 9cc0dc0..65e5dab 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 95d8299..085a118 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 46b7b3d..8daf65e 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 b49cd63..8043472 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 25640b8..19b0c79 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 0a46814..5e04bd3 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 2bd509b..d12901b 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 ab6dd23..5da1c2b 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 + + - + - -
    diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bc654e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +gitpython +odoo-addons-parser From c190c644d05d5a71fc1b5830d6686e625975bd8a 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/134] [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 6871b01..d06dfd6 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 315b01c..ba39bff 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 c208251..17a80c8 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 e622e07..a20c827 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 4c04dbbbb0080826ec7a11ea83d3599dff8aadff 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/134] [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 +- test-requirements.txt | 1 + 29 files changed, 121 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 create mode 100644 test-requirements.txt diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 18bbdce..8cbc14c 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 45d5b47..9a55c90 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 158310a..0000000 --- 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 88da69e..0000000 --- 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 2edc053..0000000 --- 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 1c25716..0000000 --- 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 ce14f9a..4e96bc2 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 17a80c8..355c894 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 50376d7..ef16923 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 1d664ab..ca6fa70 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 af727c8..ec5d230 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 62bbfdb..52f2904 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 2d7e781..93c9ce6 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 8fe71e2..9b84847 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 b375f8a..d59e4b2 100644 --- a/odoo_repository/views/odoo_module.xml +++ b/odoo_repository/views/odoo_module.xml @@ -11,7 +11,7 @@