├── .gitignore ├── README.md ├── conftest.py ├── forwardport ├── __init__.py ├── __manifest__.py ├── changelog │ ├── 2021-09 │ │ ├── authorship-dedup.md │ │ ├── authorship.md │ │ ├── conflict-view.md │ │ ├── draft.md │ │ ├── feedback-missing-login.md │ │ ├── followup-conflict.md │ │ ├── fp-remote-view.md │ │ ├── fwbot-rplus-error.md │ │ └── outstanding.md │ ├── 2021-10 │ │ ├── delegate-followup.md │ │ ├── followupdate-race.md │ │ ├── fw-reapproval.md │ │ └── outstanding-layout.md │ ├── 2022-06 │ │ ├── closed.md │ │ ├── conflict-diff3.md │ │ └── detached.md │ ├── 2022-10 │ │ ├── new-branch.md │ │ └── notifications.md │ └── 2023-08 │ │ └── outstanding.md ├── controllers.py ├── data │ ├── crons.xml │ ├── queues.xml │ ├── security.xml │ └── views.xml ├── migrations │ ├── 13.0.1.1 │ │ ├── post-reminder-date.py │ │ └── pre-tagging.py │ ├── 15.0.1.2 │ │ └── pre-migration.py │ ├── 15.0.1.3 │ │ └── pre-migration.py │ └── 15.0.1.4 │ │ └── pre-migration.py ├── models │ ├── __init__.py │ ├── forwardport.py │ ├── project.py │ └── project_freeze.py └── tests │ ├── conftest.py │ ├── test_backport.py │ ├── test_batches.py │ ├── test_conflicts.py │ ├── test_limit.py │ ├── test_overrides.py │ ├── test_simple.py │ ├── test_updates.py │ └── test_weird.py ├── mergebot_test_utils └── utils.py ├── requirements.txt ├── runbot ├── __init__.py ├── __manifest__.py ├── common.py ├── container.py ├── controllers │ ├── __init__.py │ ├── badge.py │ ├── errors.py │ ├── frontend.py │ └── hook.py ├── data │ ├── build_parse.xml │ ├── dockerfile_data.xml │ ├── error_link.xml │ ├── runbot_build_config_data.xml │ ├── runbot_data.xml │ ├── runbot_error_regex_data.xml │ └── website_data.xml ├── docker_manager.py ├── documentation │ ├── codeowner.md │ ├── images │ │ ├── repo_odoo.png │ │ ├── repo_runbot.png │ │ └── trigger.png │ └── readme.md ├── example_scripts │ ├── nginx.conf │ ├── runbot │ │ ├── builder.sh │ │ ├── leader.sh │ │ └── runbot.sh │ └── services │ │ ├── builder.service │ │ ├── leader.service │ │ └── runbot.service ├── fields.py ├── migrations │ ├── 15.0.5.2 │ │ └── pre-migration.py │ ├── 15.0.5.3 │ │ ├── post-migration.py │ │ └── pre-migration.py │ ├── 16.0.5.4 │ │ └── post-migration.py │ ├── 16.0.5.5 │ │ └── post-migration.py │ ├── 17.0.5.5 │ │ └── pre-migration.py │ ├── 17.0.5.6 │ │ └── pre-migration.py │ ├── 17.0.5.7 │ │ ├── post-migration.py │ │ └── pre-migration.py │ ├── 17.0.5.8 │ │ ├── post-migration.py │ │ └── pre-migration.py │ ├── 18.0.5.10 │ │ └── post-migration.py │ └── 18.0.5.9 │ │ └── post-migration.py ├── models │ ├── __init__.py │ ├── batch.py │ ├── branch.py │ ├── build.py │ ├── build_config.py │ ├── build_config_codeowner.py │ ├── build_error.py │ ├── build_error_merge.py │ ├── build_stat.py │ ├── build_stat_regex.py │ ├── bundle.py │ ├── codeowner.py │ ├── commit.py │ ├── custom_trigger.py │ ├── database.py │ ├── docker.py │ ├── host.py │ ├── ir_cron.py │ ├── ir_http.py │ ├── ir_logging.py │ ├── ir_model_fields_converter.py │ ├── ir_qweb.py │ ├── module.py │ ├── project.py │ ├── repo.py │ ├── res_config_settings.py │ ├── res_users.py │ ├── runbot.py │ ├── team.py │ ├── upgrade.py │ ├── user.py │ ├── version.py │ └── website.py ├── security │ ├── ir.model.access.csv │ ├── ir.rule.csv │ └── runbot_security.xml ├── static │ └── src │ │ ├── css │ │ └── runbot.css │ │ ├── img │ │ ├── icon_killed.png │ │ ├── icon_killed.svg │ │ ├── icon_ko.png │ │ ├── icon_ko.svg │ │ ├── icon_ok.png │ │ ├── icon_ok.svg │ │ ├── icon_skipped.png │ │ ├── icon_skipped.svg │ │ ├── icon_warn.png │ │ └── icon_warn.svg │ │ ├── js │ │ ├── fields │ │ │ ├── fields.css │ │ │ ├── fields.js │ │ │ ├── tracking_value.js │ │ │ ├── tracking_value.scss │ │ │ └── tracking_value.xml │ │ ├── log_display.js │ │ ├── runbot.js │ │ ├── stats.js │ │ └── views │ │ │ └── form_controller.js │ │ └── libs │ │ ├── bootstrap │ │ ├── LICENSE │ │ ├── css │ │ │ └── bootstrap.css │ │ └── js │ │ │ ├── alert.js │ │ │ ├── bootstrap.bundle.js │ │ │ ├── button.js │ │ │ ├── carousel.js │ │ │ ├── collapse.js │ │ │ ├── dropdown.js │ │ │ ├── index.js │ │ │ ├── modal.js │ │ │ ├── popover.js │ │ │ ├── scrollspy.js │ │ │ ├── tab.js │ │ │ ├── toast.js │ │ │ ├── tooltip.js │ │ │ └── util.js │ │ ├── diff_match_patch │ │ ├── LICENSE │ │ └── diff_match_patch.js │ │ ├── fontawesome │ │ ├── css │ │ │ └── font-awesome.css │ │ └── fonts │ │ │ ├── FontAwesome.otf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.svg │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ └── fontawesome-webfont.woff2 │ │ ├── jquery │ │ ├── jquery.browser.js │ │ └── jquery.js │ │ └── popper │ │ └── popper.js ├── templates │ ├── badge.xml │ ├── batch.xml │ ├── branch.xml │ ├── build.xml │ ├── build_error.xml │ ├── build_stats.xml │ ├── bundle.xml │ ├── commit.xml │ ├── dashboard.xml │ ├── dockerfile.xml │ ├── error_merge.xml │ ├── frontend.xml │ ├── git.xml │ ├── nginx.xml │ └── utils.xml ├── tests │ ├── __init__.py │ ├── common.py │ ├── test_batch.py │ ├── test_branch.py │ ├── test_build.py │ ├── test_build_config_step.py │ ├── test_build_error.py │ ├── test_build_stat.py │ ├── test_command.py │ ├── test_commit.py │ ├── test_cron.py │ ├── test_dockerfile.py │ ├── test_event.py │ ├── test_host.py │ ├── test_repo.py │ ├── test_runbot.py │ ├── test_schedule.py │ ├── test_upgrade.py │ └── test_version.py ├── views │ ├── branch_views.xml │ ├── build_error_link_views.xml │ ├── build_error_merge_views.xml │ ├── build_error_views.xml │ ├── build_views.xml │ ├── bundle_views.xml │ ├── codeowner_views.xml │ ├── commit_views.xml │ ├── config_views.xml │ ├── custom_trigger_wizard_views.xml │ ├── dashboard_views.xml │ ├── dockerfile_views.xml │ ├── error_log_views.xml │ ├── host_views.xml │ ├── menus.xml │ ├── repo_views.xml │ ├── res_config_settings_views.xml │ ├── stat_views.xml │ ├── upgrade.xml │ ├── user.xml │ └── warning_views.xml └── wizards │ ├── __init__.py │ ├── stat_regex_wizard.py │ └── stat_regex_wizard_views.xml ├── runbot_builder ├── builder.py ├── leader.py ├── tester.py └── tools.py ├── runbot_cla ├── __init__.py ├── __manifest__.py ├── build_config.py └── data │ └── runbot_build_config_data.xml ├── runbot_merge ├── README.rst ├── __init__.py ├── __manifest__.py ├── changelog │ ├── 2021-09 │ │ ├── conflict_authorship.md │ │ ├── different_project_link.md │ │ ├── drafts.md │ │ ├── fetch_closed.md │ │ ├── persistent_linked_prs.md │ │ ├── rebase_tagging.md │ │ ├── staging_failure_message.md │ │ └── timestamps.md │ ├── 2021-10 │ │ ├── changelog.md │ │ ├── commit-title-edition.md │ │ ├── pr_description_up_to_date.md │ │ ├── pr_errors.md │ │ ├── pr_page.md │ │ ├── review-without-email.md │ │ ├── reviewer-merge-methods.md │ │ └── squash.md │ ├── 2022-06 │ │ ├── alerts.md │ │ ├── branch.md │ │ ├── empty-body.md │ │ ├── pinging.md │ │ ├── provisioning.md │ │ ├── ui.md │ │ └── unstaging.md │ ├── 2022-10 │ │ ├── branch.md │ │ ├── labels.md │ │ ├── squash.md │ │ └── statuses.md │ ├── 2023-08 │ │ ├── opts.md │ │ ├── staging-reverse-index.md │ │ └── stagings-to-prs.md │ ├── 2023-10 │ │ └── free-the-limit.md │ ├── 2023-12 │ │ ├── commands.md │ │ ├── staging-priority.md │ │ └── staging-shutdown.md │ └── 2024-08 │ │ └── description.md ├── controllers │ ├── __init__.py │ ├── dashboard.py │ └── reviewer_provisioning.py ├── data │ ├── merge_cron.xml │ └── runbot_merge.pull_requests.feedback.template.csv ├── exceptions.py ├── git.py ├── github.py ├── localtunnel ├── migrations │ ├── 13.0.1.1 │ │ └── pre-migration.py │ ├── 13.0.1.2 │ │ └── pre-migration.py │ ├── 13.0.1.3 │ │ └── pre-migration.py │ ├── 13.0.1.4 │ │ └── pre-migration.py │ ├── 13.0.1.5 │ │ └── pre-migration.py │ ├── 13.0.1.6 │ │ └── pre-migration.py │ ├── 13.0.1.7 │ │ └── pre-migration.py │ ├── 15.0.1.10 │ │ └── pre-migration.py │ ├── 15.0.1.11 │ │ └── pre-migration.py │ ├── 15.0.1.12 │ │ └── pre-migration.py │ ├── 15.0.1.13 │ │ └── pre-migration.py │ ├── 15.0.1.14 │ │ └── pre-migration.py │ ├── 15.0.1.15 │ │ └── pre-migration.py │ ├── 15.0.1.8 │ │ ├── pre-migration.py │ │ └── upgrade.sql │ ├── 15.0.1.9 │ │ └── pre-migration.py │ └── 17.0.1.15 │ │ └── pre-migration.py ├── models │ ├── __init__.py │ ├── backport │ │ ├── __init__.py │ │ └── views.xml │ ├── batch.py │ ├── commands.py │ ├── crons │ │ ├── __init__.py │ │ ├── cleanup_scratch_branches.py │ │ ├── cleanup_scratch_branches.xml │ │ ├── git_maintenance.py │ │ ├── git_maintenance.xml │ │ ├── issues_closer.py │ │ └── issues_closer.xml │ ├── events_sources.py │ ├── ir_actions.py │ ├── ir_ui_view.py │ ├── mail_thread.py │ ├── patcher.py │ ├── project.py │ ├── project_freeze │ │ ├── __init__.py │ │ └── views.xml │ ├── pull_requests.py │ ├── res_partner.py │ ├── staging_cancel │ │ ├── __init__.py │ │ └── views.xml │ ├── stagings_create.py │ └── utils.py ├── ngrok ├── security │ ├── ir.model.access.csv │ └── security.xml ├── sentry.py ├── static │ └── scss │ │ ├── primary_variables.scss │ │ ├── runbot_merge.scss │ │ └── runbot_merge_backend.scss ├── tests │ ├── README.rst │ ├── conftest.py │ ├── test_basic.py │ ├── test_batch_consistency.py │ ├── test_by_branch.py │ ├── test_dfm.py │ ├── test_disabled_branch.py │ ├── test_multirepo.py │ ├── test_nondeterministic_failures.py │ ├── test_oddities.py │ ├── test_patching.py │ ├── test_project_toggles.py │ ├── test_provisioning.py │ ├── test_staging.py │ └── test_status_overrides.py ├── utils.py └── views │ ├── batch.xml │ ├── configuration.xml │ ├── mergebot.xml │ ├── queues.xml │ ├── res_partner.xml │ ├── runbot_merge_project.xml │ └── templates.xml └── runbot_populate ├── __init__.py ├── __manifest__.py ├── demo └── runbot_demo.xml └── models ├── __init__.py └── runbot.py /.gitignore: -------------------------------------------------------------------------------- 1 | # dotfiles 2 | .* 3 | # compiled python files 4 | *.py[co] 5 | # emacs backup files 6 | *~ 7 | # runbot work files 8 | runbot/static/build 9 | runbot/static/repo 10 | runbot/static/sources 11 | runbot/static/nginx 12 | runbot/static/databases 13 | runbot/static/docker 14 | runbot/static/docker-registry 15 | -------------------------------------------------------------------------------- /forwardport/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models 2 | from . import controllers 3 | -------------------------------------------------------------------------------- /forwardport/__manifest__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | { 3 | 'name': 'forward port bot', 4 | 'version': '1.4', 5 | 'summary': "A port which forward ports successful PRs.", 6 | 'depends': ['runbot_merge'], 7 | 'data': [ 8 | 'data/security.xml', 9 | 'data/crons.xml', 10 | 'data/views.xml', 11 | 'data/queues.xml', 12 | ], 13 | 'license': 'LGPL-3', 14 | } 15 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/authorship-dedup.md: -------------------------------------------------------------------------------- 1 | FIX: the deduplication of authorship in case of conflicts in multi-commit PRs 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/authorship.md: -------------------------------------------------------------------------------- 1 | FIX: loss of authorship on conflicts in multi-commit PRs, such conflicts now generate a commit with no authorship information, which can not be merged 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/conflict-view.md: -------------------------------------------------------------------------------- 1 | ADD: better localisation of conflicts in multi-PR commits, list all the commits in the comment and add an arrow pointing to the one which broke 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/draft.md: -------------------------------------------------------------------------------- 1 | REM: creation of forward ports in draft mode 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/feedback-missing-login.md: -------------------------------------------------------------------------------- 1 | FIX: some feedback messages didn't correctly ping the person being replied to 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/followup-conflict.md: -------------------------------------------------------------------------------- 1 | IMP: properly notify the user when an update to a pull request causes a conflict when impacted on the followup 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/fp-remote-view.md: -------------------------------------------------------------------------------- 1 | IMP: add the forward-port remote to the repository view, so it can be set via the UI 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/fwbot-rplus-error.md: -------------------------------------------------------------------------------- 1 | IMP: error messages when trying to `@fw-bot r+` on pull requests not under its purview 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-09/outstanding.md: -------------------------------------------------------------------------------- 1 | ADD: list of outstanding forward-ports 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-10/delegate-followup.md: -------------------------------------------------------------------------------- 1 | FIX: allow delegate reviewers *on forward ports* to approve the followups, it worked fine for delegates on the original pull request but a delegation on a forward port would only work for that specific PR (note: only works if the followups don't already exist) 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-10/followupdate-race.md: -------------------------------------------------------------------------------- 1 | FIX: rare condition where updating a forwardport would then require all followups to be individually approved 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-10/fw-reapproval.md: -------------------------------------------------------------------------------- 1 | FIX: don't trigger an error message when using `fw-bot r+` and some of the PRs were already approved 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2021-10/outstanding-layout.md: -------------------------------------------------------------------------------- 1 | IMP: layout and features of the "outstanding forward port" page, show the oldest-merged PRs first and allow filtering by reviewer 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2022-06/closed.md: -------------------------------------------------------------------------------- 1 | IMP: notifications when reopening a closed forward-port (e.g. indicate that they're detached) 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2022-06/conflict-diff3.md: -------------------------------------------------------------------------------- 1 | IMP: use the `diff3` conflict style, should make forward port conflicts clearer and easier to fix 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2022-06/detached.md: -------------------------------------------------------------------------------- 1 | IMP: flag detached PRs in their dashboard 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2022-10/new-branch.md: -------------------------------------------------------------------------------- 1 | FIX: creation of forward-port PRs for new (freeze) branches in edge cases 2 | -------------------------------------------------------------------------------- /forwardport/changelog/2022-10/notifications.md: -------------------------------------------------------------------------------- 1 | FIX: stop pinging the forwardbot on forward-port PRs 2 | 3 | Also ping the reviewer of the original PR. 4 | -------------------------------------------------------------------------------- /forwardport/changelog/2023-08/outstanding.md: -------------------------------------------------------------------------------- 1 | IMP: outstandings page 2 | 3 | - increased time-before-outstanding from 3 to 7 days, as 3~4 days is common in 4 | normal operations, especially when merging from very low branches were 5 | forward-porting may take a while 6 | - improved performances by optimising fetching & filtering 7 | - added counts to the main listing for clarity (instead of hiding them in a 8 | popover) 9 | - added the *original authors* for the outstanding forward ports 10 | - added ability to filter by team, if such are configured 11 | -------------------------------------------------------------------------------- /forwardport/data/crons.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Check if there are merged PRs to port 4 | 5 | code 6 | model._process() 7 | 6 8 | hours 9 | -1 10 | 11 | 43 12 | 13 | 14 | 15 | Update followup FP PRs 16 | 17 | code 18 | model._process() 19 | 6 20 | hours 21 | -1 22 | 23 | 46 24 | 25 | 26 | 27 | Remind open PR 28 | 29 | code 30 | model._reminder() 31 | 1 32 | days 33 | -1 34 | 35 | 36 | 37 | 38 | Remove branches of merged PRs 39 | 40 | code 41 | model._process() 42 | 6 43 | hours 44 | -1 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /forwardport/data/queues.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Forward port batches 4 | forwardport.batches 5 | {'active_test': False} 6 | 7 | 8 | Forward port batches 9 | forwardport.batches 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Forward port batch 20 | forwardport.batches 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Followup Updates 38 | forwardport.updates 39 | 40 | 41 | Followup Updates 42 | forwardport.updates 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 54 | 57 | 58 | -------------------------------------------------------------------------------- /forwardport/data/security.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Admin access to batches 4 | 5 | 6 | 1 7 | 1 8 | 1 9 | 1 10 | 11 | 12 | Admin access to updates 13 | 14 | 15 | 1 16 | 1 17 | 1 18 | 1 19 | 20 | 21 | Admin access to branch remover 22 | 23 | 24 | 1 25 | 1 26 | 1 27 | 1 28 | 29 | 30 | 31 | No normal access to batches 32 | 33 | 0 34 | 0 35 | 0 36 | 0 37 | 38 | 39 | No normal access to updates 40 | 41 | 0 42 | 0 43 | 0 44 | 0 45 | 46 | 47 | -------------------------------------------------------------------------------- /forwardport/migrations/13.0.1.1/post-reminder-date.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ Set the merge_date field to the current write_date, and reset 3 | the backoff to its default so we reprocess old PRs properly. 4 | """ 5 | cr.execute(""" 6 | UPDATE runbot_merge_pull_requests 7 | SET merge_date = write_date, 8 | reminder_backoff_factor = -4 9 | WHERE state = 'merged' 10 | """) 11 | -------------------------------------------------------------------------------- /forwardport/migrations/13.0.1.1/pre-tagging.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("delete from ir_model where model = 'forwardport.tagging'") 3 | -------------------------------------------------------------------------------- /forwardport/migrations/15.0.1.2/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ Add a dummy detach reason to detached PRs. 3 | """ 4 | cr.execute( 5 | "ALTER TABLE runbot_merge_pull_requests" 6 | " ADD COLUMN detach_reason varchar" 7 | ) 8 | cr.execute( 9 | "UPDATE runbot_merge_pull_requests" 10 | " SET detach_reason = 'unknown'" 11 | " WHERE source_id IS NOT NULL AND parent_id IS NULL") 12 | -------------------------------------------------------------------------------- /forwardport/migrations/15.0.1.3/pre-migration.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from odoo.tools.appdirs import user_cache_dir 4 | 5 | 6 | def migrate(_cr, _version): 7 | # avoid needing to re-clone our repo unnecessarily 8 | pathlib.Path(user_cache_dir('forwardport')).rename( 9 | pathlib.Path(user_cache_dir('mergebot'))) 10 | -------------------------------------------------------------------------------- /forwardport/migrations/15.0.1.4/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("ALTER TABLE runbot_merge_project DROP COLUMN IF EXISTS fp_github_email") 3 | cr.execute(""" 4 | ALTER TABLE runbot_merge_branch 5 | DROP COLUMN IF EXISTS fp_sequence, 6 | DROP COLUMN IF EXISTS fp_target 7 | """) 8 | -------------------------------------------------------------------------------- /forwardport/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import project 3 | from . import project_freeze 4 | from . import forwardport 5 | -------------------------------------------------------------------------------- /forwardport/models/project_freeze.py: -------------------------------------------------------------------------------- 1 | from odoo import models 2 | 3 | 4 | class FreezeWizard(models.Model): 5 | """ Override freeze wizard to disable the forward port cron when one is 6 | created (so there's a freeze ongoing) and re-enable it once all freezes are 7 | done. 8 | 9 | If there ever is a case where we have lots of projects, 10 | """ 11 | _inherit = 'runbot_merge.project.freeze' 12 | 13 | def create(self, vals_list): 14 | r = super().create(vals_list) 15 | self.env.ref('forwardport.port_forward').active = False 16 | return r 17 | 18 | def action_freeze(self): 19 | return super(FreezeWizard, self.with_context(forwardport_keep_disabled=True))\ 20 | .action_freeze() 21 | 22 | def unlink(self): 23 | r = super().unlink() 24 | if not (self.env.context.get('forwardport_keep_disabled') or self.search_count([])): 25 | cron = self.env.ref('forwardport.port_forward') 26 | cron.active = True 27 | cron._trigger() # process forward ports enqueued during the freeze period 28 | return r 29 | -------------------------------------------------------------------------------- /forwardport/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | import pytest 5 | import requests 6 | 7 | # public_repo — necessary to leave comments 8 | # admin:repo_hook — to set up hooks (duh) 9 | # delete_repo — to cleanup repos created under a user 10 | # user:email — fetch token/user's email addresses 11 | TOKEN_SCOPES = { 12 | 'github': {'admin:repo_hook', 'delete_repo', 'public_repo', 'user:email'}, 13 | # TODO: user:email so they can fetch the user's email? 14 | 'role_reviewer': {'public_repo'},# 'delete_repo'}, 15 | 'role_self_reviewer': {'public_repo'},# 'delete_repo'}, 16 | 'role_other': {'public_repo'},# 'delete_repo'}, 17 | } 18 | @pytest.fixture(autouse=True, scope='session') 19 | def _check_scopes(config): 20 | for section, vals in config.items(): 21 | required_scopes = TOKEN_SCOPES.get(section) 22 | if required_scopes is None: 23 | continue 24 | 25 | response = requests.get('https://api.github.com/rate_limit', headers={ 26 | 'Authorization': 'token %s' % vals['token'] 27 | }) 28 | assert response.status_code == 200 29 | x_oauth_scopes = response.headers['X-OAuth-Scopes'] 30 | token_scopes = set(re.split(r',\s+', x_oauth_scopes)) 31 | assert token_scopes >= required_scopes, \ 32 | "%s should have scopes %s, found %s" % (section, token_scopes, required_scopes) 33 | 34 | @pytest.fixture() 35 | def module(): 36 | """ When a test function is (going to be) run, selects the containing 37 | module (as needing to be installed) 38 | """ 39 | # NOTE: no request.fspath (because no request.function) in session-scoped fixture so can't put module() at the toplevel 40 | return 'forwardport' 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==3.5.1; python_version < '3.12' 2 | matplotlib==3.6.3; python_version >= '3.12' 3 | numpy==1.22.0; python_version < '3.12' # for matplotlib compatibility, 1.21.5 on jammy, but this version is not available on some distributions because of CVE 4 | numpy==1.26.4; python_version >= '3.12' 5 | unidiff==0.5.5; python_version < '3.12' 6 | unidiff==0.7.3; python_version >= '3.12' 7 | docker==5.0.3; 8 | -------------------------------------------------------------------------------- /runbot/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import controllers 4 | from . import models 5 | from . import common 6 | from . import container 7 | from . import wizards 8 | 9 | import logging 10 | import threading 11 | from odoo.http import request 12 | 13 | # rng validators doesn't allow decoration-bg-attributes on list fields even if they work fine (as long as you don't have a widget) 14 | # disabling rng validators for list as they have a low value (as long as we test the views manually witch is the case on runbot) 15 | from odoo.tools.view_validation import _validators 16 | _validators['list'] = [] 17 | 18 | class UserFilter(logging.Filter): 19 | def filter(self, record): # noqa: A003 20 | message_parts = record.msg.split(' ', 2) 21 | if message_parts[1] == '-': 22 | uid = getattr(threading.current_thread(), 'uid', None) 23 | if uid is None: 24 | return True 25 | user_name = 'user' 26 | if hasattr(threading.current_thread(), 'user_name'): 27 | user_name = threading.current_thread().user_name 28 | del(threading.current_thread().user_name) 29 | message_parts[1] = f'({user_name}:{uid})' 30 | record.msg = ' '.join(message_parts) 31 | return True 32 | 33 | 34 | def runbot_post_load(): 35 | logging.getLogger('werkzeug').addFilter(UserFilter()) 36 | 37 | -------------------------------------------------------------------------------- /runbot/__manifest__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | { 3 | 'name': "runbot", 4 | 'summary': "Runbot", 5 | 'description': "Runbot for Odoo 17.0", 6 | 'author': "Odoo SA", 7 | 'website': "http://runbot.odoo.com", 8 | 'category': 'Website', 9 | 'version': '5.10', 10 | 'application': True, 11 | 'depends': ['base', 'base_automation', 'website'], 12 | 'data': [ 13 | 'templates/dockerfile.xml', 14 | 'data/dockerfile_data.xml', 15 | 'data/build_parse.xml', 16 | 'data/error_link.xml', 17 | 'data/runbot_build_config_data.xml', 18 | 'data/runbot_data.xml', 19 | 'data/runbot_error_regex_data.xml', 20 | 'data/website_data.xml', 21 | 22 | 'security/runbot_security.xml', 23 | 'security/ir.model.access.csv', 24 | 'security/ir.rule.csv', 25 | 26 | 'templates/utils.xml', 27 | 'templates/badge.xml', 28 | 'templates/batch.xml', 29 | 'templates/branch.xml', 30 | 'templates/build.xml', 31 | 'templates/build_stats.xml', 32 | 'templates/bundle.xml', 33 | 'templates/commit.xml', 34 | 'templates/dashboard.xml', 35 | 'templates/error_merge.xml', 36 | 'templates/frontend.xml', 37 | 'templates/git.xml', 38 | 'templates/nginx.xml', 39 | 'templates/build_error.xml', 40 | 41 | 'views/branch_views.xml', 42 | 'views/build_error_link_views.xml', 43 | 'views/build_error_views.xml', 44 | 'views/build_error_merge_views.xml', 45 | 'views/build_views.xml', 46 | 'views/bundle_views.xml', 47 | 'views/codeowner_views.xml', 48 | 'views/commit_views.xml', 49 | 'views/config_views.xml', 50 | 'views/dashboard_views.xml', 51 | 'views/dockerfile_views.xml', 52 | 'views/error_log_views.xml', 53 | 'views/host_views.xml', 54 | 'views/repo_views.xml', 55 | 'views/res_config_settings_views.xml', 56 | 'views/stat_views.xml', 57 | 'views/upgrade.xml', 58 | 'views/warning_views.xml', 59 | 'views/custom_trigger_wizard_views.xml', 60 | 'wizards/stat_regex_wizard_views.xml', 61 | 'views/menus.xml', 62 | 'views/user.xml', 63 | ], 64 | 'license': 'LGPL-3', 65 | 66 | 'assets': { 67 | 'web.assets_backend': [ 68 | 'runbot/static/src/libs/diff_match_patch/diff_match_patch.js', 69 | 'runbot/static/src/js/views/**/*', 70 | 'runbot/static/src/js/fields/*', 71 | ], 72 | 'runbot.assets_frontend': [ 73 | '/web/static/lib/bootstrap/dist/css/bootstrap.css', 74 | '/web/static/src/libs/fontawesome/css/font-awesome.css', 75 | '/runbot/static/src/css/runbot.css', 76 | 77 | '/web/static/lib/jquery/jquery.js', 78 | '/web/static/lib/popper/popper.js', 79 | #'/web/static/lib/bootstrap/js/dist/util.js', 80 | '/web/static/lib/bootstrap/js/dist/dropdown.js', 81 | '/web/static/lib/bootstrap/js/dist/collapse.js', 82 | '/runbot/static/src/js/runbot.js', 83 | ], 84 | }, 85 | 'post_load': 'runbot_post_load', 86 | } 87 | -------------------------------------------------------------------------------- /runbot/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import frontend 4 | from . import hook 5 | from . import badge 6 | from . import errors 7 | -------------------------------------------------------------------------------- /runbot/controllers/badge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import hashlib 3 | 4 | import werkzeug 5 | from matplotlib.font_manager import FontProperties 6 | from matplotlib.textpath import TextToPath 7 | 8 | from odoo.http import request, route, Controller 9 | 10 | 11 | class RunbotBadge(Controller): 12 | 13 | @route([ 14 | '/runbot/badge//.svg', 15 | '/runbot/badge/trigger//.svg', 16 | '/runbot/badge///.svg', 17 | '/runbot/badge/trigger///.svg', 18 | ], type="http", auth="public", methods=['GET', 'HEAD'], sitemap=False) 19 | def badge(self, name, repo_id=False, trigger_id=False, theme='default'): 20 | # Sudo is used here to allow the badge to be returned for projects 21 | # which have restricted permissions. 22 | Trigger = request.env['runbot.trigger'].sudo() 23 | Repo = request.env['runbot.repo'].sudo() 24 | Batch = request.env['runbot.batch'].sudo() 25 | Bundle = request.env['runbot.bundle'].sudo() 26 | if trigger_id: 27 | triggers = Trigger.browse(trigger_id) 28 | project = triggers.project_id 29 | else: 30 | triggers = Trigger.search([('repo_ids', 'in', repo_id)]) 31 | project = Repo.browse(repo_id).project_id 32 | # -> hack to use repo. Would be better to change logic and use a trigger_id in params 33 | bundle = Bundle.search([('name', '=', name), 34 | ('project_id', '=', project.id)]) 35 | if not bundle or not triggers: 36 | return request.not_found() 37 | batch = Batch.search([ 38 | ('bundle_id', '=', bundle.id), 39 | ('state', '=', 'done'), 40 | ('category_id', '=', request.env.ref('runbot.default_category').id) 41 | ], order='id desc', limit=1) 42 | 43 | builds = batch.slot_ids.filtered(lambda s: s.trigger_id in triggers).mapped('build_id') 44 | if not builds: 45 | state = 'testing' 46 | else: 47 | result = builds._result_multi() 48 | if result == 'ok': 49 | state = 'success' 50 | elif result == 'warn': 51 | state = 'warning' 52 | else: 53 | state = 'failed' 54 | 55 | etag = request.httprequest.headers.get('If-None-Match') 56 | retag = hashlib.md5(state.encode()).hexdigest() 57 | if etag == retag: 58 | return werkzeug.wrappers.Response(status=304) 59 | 60 | # from https://github.com/badges/shields/blob/master/colorscheme.json 61 | color = { 62 | 'testing': "#dfb317", 63 | 'success': "#4c1", 64 | 'failed': "#e05d44", 65 | 'warning': "#fe7d37", 66 | }[state] 67 | 68 | def text_width(s): 69 | fp = FontProperties(family='DejaVu Sans', size=11) 70 | w, h, d = TextToPath().get_text_width_height_descent(s, fp, False) 71 | return int(w + 1) 72 | 73 | class Text(object): 74 | __slot__ = ['text', 'color', 'width'] 75 | 76 | def __init__(self, text, color): 77 | self.text = text 78 | self.color = color 79 | self.width = text_width(text) + 10 80 | 81 | data = { 82 | 'left': Text(name, '#555'), 83 | 'right': Text(state, color), 84 | } 85 | headers = [ 86 | ('Content-Type', 'image/svg+xml'), 87 | ('Cache-Control', 'max-age=%d' % (10*60,)), 88 | ('ETag', retag), 89 | ] 90 | return request.render("runbot.badge_" + theme, data, headers=headers) 91 | -------------------------------------------------------------------------------- /runbot/controllers/errors.py: -------------------------------------------------------------------------------- 1 | from odoo.http import Controller, Response, request, route 2 | 3 | 4 | class ErrorController(Controller): 5 | 6 | @route('/runbot/error/merge/result/', type='http', auth='public', website=True) 7 | def error_filter_result(self, filter_id=None, **kwargs): 8 | merger = request.env['runbot.build.error.merge'].browse(int(filter_id)) 9 | if not merger: 10 | return Response('Error merge not found', status=404) 11 | return request.render('runbot.error_merge_result', {'merger': merger, 'results': merger._get_matching_groups()}) 12 | -------------------------------------------------------------------------------- /runbot/controllers/hook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | import json 5 | import logging 6 | 7 | from odoo import http 8 | from odoo.http import request 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | 13 | class Hook(http.Controller): 14 | 15 | @http.route(['/runbot/hook', '/runbot/hook/'], type='http', auth="public", website=True, csrf=False, sitemap=False) 16 | def hook(self, remote_id=None, **_post): 17 | event = request.httprequest.headers.get("X-Github-Event") 18 | payload = json.loads(request.params.get('payload', '{}')) 19 | if remote_id is None: 20 | repo_data = payload.get('repository') 21 | if repo_data: 22 | remote_domain = [ 23 | '|', '|', '|', 24 | ('name', '=', repo_data['ssh_url']), 25 | ('name', '=', repo_data['ssh_url'].replace('.git', '')), 26 | ('name', '=', repo_data['clone_url']), 27 | ('name', '=', repo_data['clone_url'].replace('.git', '')), 28 | ] 29 | remote = request.env['runbot.remote'].sudo().search( 30 | remote_domain, limit=1) 31 | remote_id = remote.id 32 | if not remote_id: 33 | _logger.error("Remote %s not found", repo_data['ssh_url']) 34 | remote = request.env['runbot.remote'].sudo().browse(remote_id) 35 | 36 | # force update of dependencies too in case a hook is lost 37 | if not payload or event == 'push': 38 | remote.repo_id._set_hook_time(time.time()) 39 | elif event == 'pull_request': 40 | pr_number = payload.get('pull_request', {}).get('number', '') 41 | branch = request.env['runbot.branch'].sudo().search([('remote_id', '=', remote.id), ('name', '=', pr_number)]) 42 | branch._recompute_infos(payload.get('pull_request', {})) 43 | if payload.get('action') in ('synchronize', 'opened', 'reopened'): 44 | remote.repo_id._set_hook_time(time.time()) 45 | # remaining recurrent actions: labeled, review_requested, review_request_removed 46 | elif event == 'delete': 47 | if payload.get('ref_type') == 'branch': 48 | branch_ref = payload.get('ref') 49 | _logger.info('Branch %s in repo %s was deleted', branch_ref, remote.repo_id.name) 50 | branch = request.env['runbot.branch'].sudo().search([('remote_id', '=', remote.id), ('name', '=', branch_ref)]) 51 | branch.alive = False 52 | return "" 53 | -------------------------------------------------------------------------------- /runbot/data/build_parse.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Parse build logs 4 | 5 | 6 | ir.actions.server 7 | code 8 | 9 | action = records._parse_logs() 10 | 11 | 12 | 13 | Parse log entry 14 | 15 | 16 | ir.actions.server 17 | code 18 | 19 | action = records._parse_logs() 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /runbot/data/error_link.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Merge build errors 4 | 5 | 6 | ir.actions.server 7 | code 8 | 9 | records.action_link_errors() 10 | 11 | 12 | 13 | Link build errors contents 14 | 15 | 16 | ir.actions.server 17 | code 18 | 19 | records.action_link_errors_contents() 20 | 21 | 22 | 23 | 24 | Extract build error contents 25 | 26 | 27 | ir.actions.server 28 | code 29 | 30 | action = records.action_extract_errors_contents() 31 | 32 | 33 | 34 | 35 | Re-clean build errors 36 | 37 | 38 | ir.actions.server 39 | code 40 | 41 | records.action_clean_content() 42 | 43 | 44 | 45 | Re-assign build errors 46 | 47 | 48 | ir.actions.server 49 | code 50 | 51 | records.action_assign() 52 | 53 | 54 | 55 | Deduplicate Error Contents 56 | 57 | 58 | ir.actions.server 59 | code 60 | 61 | records.action_deduplicate() 62 | 63 | 64 | 65 | View build errors 66 | 67 | ir.actions.server 68 | code 69 | 70 | action = records.action_view_build_errors() 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /runbot/data/runbot_error_regex_data.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | , line \d+, 6 | cleaning 7 | 8 | 9 | Module .+: \d+ failures, \d+ errors 10 | filter 11 | 12 | 13 | At least one test failed when loading the modules. 14 | filter 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /runbot/data/website_data.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | /home 4 | 5 | 6 | -------------------------------------------------------------------------------- /runbot/docker_manager.py: -------------------------------------------------------------------------------- 1 | 2 | import getpass 3 | import logging 4 | import time 5 | import warnings 6 | import re 7 | 8 | # unsolved issue https://github.com/docker/docker-py/issues/2928 9 | with warnings.catch_warnings(): 10 | warnings.filterwarnings( 11 | "ignore", 12 | message="The distutils package is deprecated.*", 13 | category=DeprecationWarning, 14 | ) 15 | import docker 16 | 17 | USERNAME = getpass.getuser() 18 | 19 | _logger = logging.getLogger(__name__) 20 | docker_stop_failures = {} 21 | 22 | 23 | class DockerManager: 24 | def __init__(self, image_tag): 25 | self.image_tag = image_tag 26 | 27 | def __enter__(self): 28 | self.start = time.time() 29 | self.duration = 0 30 | self.docker_client = docker.from_env() 31 | self.result = { 32 | 'msg': '', 33 | 'image': False, 34 | 'success': True, 35 | } 36 | self.log_progress = False 37 | return self 38 | 39 | def consume(self, stream): 40 | for chunk in docker.utils.json_stream.json_stream(stream): 41 | self.duration = time.time() - self.start 42 | if 'error' in chunk: 43 | _logger.error(chunk['error']) 44 | self.result['msg'] += chunk['error'] 45 | # self.result['msg'] += str(chunk.get('errorDetail', '')) 46 | self.result['msg'] += '\n' 47 | self.result['success'] = False 48 | break 49 | if 'stream' in chunk: 50 | self.result['msg'] += chunk['stream'] 51 | if 'status' in chunk: 52 | self.result['msg'] += chunk['status'] 53 | if 'progress' in chunk: 54 | self.result['msg'] += chunk['progress'] 55 | self.result['msg'] += '\n' 56 | yield chunk 57 | 58 | def __exit__(self, exception_type, exception_value, exception_traceback): 59 | if self.log_progress: 60 | _logger.info('Finished in %.2fs', self.duration) 61 | self.result['log_progress'] = self.log_progress 62 | if exception_value: 63 | self.result['success'] = False 64 | _logger.warning(exception_value) 65 | self.result['msg'] += str(exception_value) 66 | self.result['duration'] = self.duration 67 | if self.result['success']: 68 | self.result['image'] = self.docker_client.images.get(self.image_tag) 69 | if 'image_id' in self.result: 70 | if self.result['image_id'] not in self.result['image'].id: 71 | _logger.warning('Image id does not match %s %s', self.result['image_id'], self.result['image'].id) 72 | elif self.result['image']: 73 | match = re.search( 74 | r'^sha256:([0-9a-f]+)$', 75 | self.result['image'].id, 76 | ) 77 | if match: 78 | self.result['image_id'] = match.group(1) 79 | 80 | # if this never triggers, we could remove or simplify the success check from docker_build 81 | -------------------------------------------------------------------------------- /runbot/documentation/codeowner.md: -------------------------------------------------------------------------------- 1 | # Teams and Codeowner 2 | 3 | ## How 4 | 5 | Codeowner is using two way to define which team should be notified when a file is modified: 6 | 7 | - Module ownership to link a module to a team. (Editable by team manager) 8 | - Regexes, to target specific files or more specific rules. (Editable by runbot admin) 9 | 10 | For each file, the codeowner will check all regexes and all module ownership. 11 | If a module ownersip is a `fallback`, the team won't be added as a reviewer if any previous rule matched for a file. 12 | If no reviewer is found for a file, a fallback github team is added as reviewer. 13 | 14 | The codeowner is not applied on draft pull request (and will give a red ci as a reminder) 15 | A pr is considered draft if: 16 | 17 | - marked as draft on github 18 | - contains `[DRAFT]` or `[WIP]` in the title 19 | - is linked to any other draft pr (in the same bundle) 20 | 21 | The codeowner is not applied on forwardport initial push. Any following push (conflict resolution) will trigger the codeowner again. 22 | 23 | ## Module ownership 24 | 25 | Module ownership links a module to a team with an additionnal `is_fallback` flag to define if the codeowner should only be triggered if no one else was added for a file. 26 | 27 | Module ownership is also a way to define which team should be contacted for some question on a module. 28 | 29 | Module coverage should idealy reach 100% with module ownership. Having all files covered allows to ensure that at least one reviewer will be added when a pr is open (mainly for external contributors). This can sometimes generate to much github notifications, this is why it is important to configure members, create subteams, and skip pr policy. 30 | 31 | ## Team management 32 | 33 | Team managers can be anyone from the team with a basic knowledge of the guidelines to follow and a good understanding of the system. 34 | 35 | Team manager can modify. 36 | 37 | - Teams 38 | - Module ownership 39 | - Github account of users 40 | 41 | Some basic config can be done on Teams 42 | 43 | - `Github team`: the corresponding github team to add as reviewer 44 | - `Github logins`: additional github logins in the github team, mainly for github users no listed in the members of the runbot team. Mainly usefull if `Skip team pr` is checked. This list can be updated automatically using the `Fetch members` action. This field can also be manually modified to avoid being notified by some github login, even if it is adviced to add them as a `Team members` if they have an internal user. 45 | - `Skip team pr`: If checked, don't add the team as reviewer if the pr was oppened by one of the members of the team. 46 | - `Module Ownership`: The list of modules owned by the team. `Fallback` options can be edited from there but it is adviced to use the `Modules` or `Module ownership` menu to add or remove a module, mainly to avoid removing all ownership from a module. 47 | - `Team members`: the members of the team. Those members will see a link to the team dashboard and team errors count will be displayed on main page 48 | 49 | ## Disable codeowner on demand 50 | 51 | In some rare cases, if a pr modifies a lot of files in an almost automated way, it can be useful to disable the codeowner. This can be done on a bundle. Note that forwardport won't be impacted, and this should be done per forwardport in case of conflict. 52 | -------------------------------------------------------------------------------- /runbot/documentation/images/repo_odoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/documentation/images/repo_odoo.png -------------------------------------------------------------------------------- /runbot/documentation/images/repo_runbot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/documentation/images/repo_runbot.png -------------------------------------------------------------------------------- /runbot/documentation/images/trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/documentation/images/trigger.png -------------------------------------------------------------------------------- /runbot/documentation/readme.md: -------------------------------------------------------------------------------- 1 | # User documentation 2 | 3 | - [Teams and codeowner](codeowner.md) -------------------------------------------------------------------------------- /runbot/example_scripts/nginx.conf: -------------------------------------------------------------------------------- 1 | # only needed if not defined yet 2 | map $http_upgrade $connection_upgrade { 3 | default upgrade; 4 | '' close; 5 | } 6 | 7 | proxy_read_timeout 600; 8 | proxy_connect_timeout 600; 9 | proxy_set_header X-Forwarded-Host $remote_addr; 10 | proxy_set_header X-Forwarded-For $remote_addr; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header Host $host; 13 | 14 | server { 15 | # runbot frontend 16 | listen 80; 17 | listen [::]:80; 18 | server_name runbot.domain.com; 19 | 20 | location / { 21 | proxy_pass http://127.0.0.1:8069; 22 | } 23 | 24 | # runbot frontend notifications: optionnal 25 | location /longpolling { 26 | proxy_pass http://127.0.0.1:8070; 27 | } 28 | # not tested yet, replacement of longpolling to websocket for odoo 16.0 29 | # location /websocket { 30 | # proxy_set_header X-Forwarded-Host $remote_addr; 31 | # proxy_set_header X-Forwarded-For $remote_addr; 32 | # proxy_set_header X-Real-IP $remote_addr; 33 | # proxy_set_header Host $host; 34 | # proxy_set_header Upgrade $http_upgrade; 35 | # proxy_set_header Connection $connection_upgrade; 36 | # proxy_pass http://127.0.0.1:8080; 37 | # } 38 | 39 | # serve text log, zip, other docker outputs ... 40 | # server_name should be the same as the local builder (foced-host-name) 41 | location /runbot/static/ { 42 | alias /home/runbot_user/odoo/runbot/runbot/static/; 43 | autoindex off; 44 | location ~ /runbot/static/build/[^/]+/(logs|tests)/ { 45 | autoindex on; 46 | add_header 'Access-Control-Allow-Origin' 'http://runbot.domain.com'; 47 | } 48 | } 49 | } 50 | 51 | server { 52 | # config for running builds 53 | # subdomain redirect to the local runbot nginx with dynamic config 54 | # anothe nginx layer will listen to the 8080 port and redirect to the correct instance 55 | server_name *.runbot.domain.com; 56 | location / { 57 | proxy_set_header Host $host:$proxy_port; 58 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 59 | proxy_set_header X-Forwarded-Proto $scheme; 60 | proxy_set_header X-Real-IP $remote_addr; 61 | proxy_set_header X-Forwarded-Host $host; 62 | proxy_pass http://127.0.0.1:8080; 63 | } 64 | # needed for v16.0 websockets 65 | location /websocket { 66 | proxy_set_header Host $host:$proxy_port; 67 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 68 | proxy_set_header X-Forwarded-Proto $scheme; 69 | proxy_set_header X-Real-IP $remote_addr; 70 | proxy_set_header X-Forwarded-Host $host; 71 | proxy_set_header Upgrade $http_upgrade; 72 | proxy_set_header Connection $connection_upgrade; 73 | proxy_pass http://127.0.0.1:8080; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /runbot/example_scripts/runbot/builder.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | workdir=/home/$USER/odoo 3 | exec python3 $workdir/runbot/runbot_builder/builder.py --odoo-path $workdir/odoo -d runbot --logfile $workdir/logs/runbot_builder.txt --forced-host-name runbot.domain.com 4 | -------------------------------------------------------------------------------- /runbot/example_scripts/runbot/leader.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | workdir=/home/$USER/odoo/ 3 | exec python3 $workdir/runbot/runbot_builder/leader.py --odoo-path $workdir/odoo -d runbot --logfile $workdir/logs/runbot_leader.txt --forced-host-name=leader 4 | -------------------------------------------------------------------------------- /runbot/example_scripts/runbot/runbot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | workdir=/home/$USER/odoo 3 | exec python3 $workdir/odoo/odoo-bin --workers=2 --without-demo=1 --max-cron-thread=1 --addons-path $workdir/odoo/addons,$workdir/runbot -d runbot --logfile $workdir/logs/runbot.txt 4 | -------------------------------------------------------------------------------- /runbot/example_scripts/services/builder.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=runbot 3 | 4 | [Service] 5 | PassEnvironment=LANG 6 | Type=simple 7 | User=runbot_user 8 | WorkingDirectory=/home/runbot_user/odoo 9 | ExecStart=/home/runbot_user/bin/runbot/builder.sh 10 | Restart=on-failure 11 | KillMode=process 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | 16 | -------------------------------------------------------------------------------- /runbot/example_scripts/services/leader.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=runbot 3 | 4 | [Service] 5 | PassEnvironment=LANG 6 | Type=simple 7 | User=runbot_user 8 | WorkingDirectory=/home/runbot_user/odoo 9 | ExecStart=/home/runbot_user/bin/runbot/leader.sh 10 | Restart=on-failure 11 | KillMode=process 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | 16 | -------------------------------------------------------------------------------- /runbot/example_scripts/services/runbot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=runbot 3 | 4 | [Service] 5 | PassEnvironment=LANG 6 | Type=simple 7 | User=runbot_user 8 | WorkingDirectory=/home/runbot_user/odoo 9 | ExecStart=/home/runbot_user/bin/runbot/runbot.sh 10 | Restart=on-failure 11 | KillMode=process 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | 16 | -------------------------------------------------------------------------------- /runbot/fields.py: -------------------------------------------------------------------------------- 1 | from odoo.fields import Field 2 | from collections.abc import MutableMapping 3 | from psycopg2.extras import Json 4 | 5 | 6 | class JsonDictField(Field): 7 | type = 'jsonb' 8 | column_type = ('jsonb', 'jsonb') 9 | column_cast_from = ('varchar',) 10 | 11 | def convert_to_write(self, value, record): 12 | return value 13 | 14 | def convert_to_column(self, value, record, values=None, validate=True): 15 | val = self.convert_to_cache(value, record, validate=validate) 16 | return Json(val) if val else None 17 | 18 | def convert_to_cache(self, value, record, validate=True): 19 | return value.dict if isinstance(value, FieldDict) else value if isinstance(value, dict) else None 20 | 21 | def convert_to_record(self, value, record): 22 | return FieldDict(value or {}, self, record) 23 | 24 | def convert_to_read(self, value, record, use_name_get=True): 25 | return self.convert_to_cache(value, record) 26 | 27 | 28 | class FieldDict(MutableMapping): 29 | 30 | def __init__(self, init_dict, field, record): 31 | self.field = field 32 | self.record = record 33 | self.dict = init_dict 34 | 35 | def __setitem__(self, key, value): 36 | new = self.dict.copy() 37 | new[key] = value 38 | self.record[self.field.name] = new 39 | 40 | def __getitem__(self, key): 41 | return self.dict[key] 42 | 43 | def __delitem__(self, key): 44 | new = self.dict.copy() 45 | del new[key] 46 | self.record[self.field.name] = new 47 | 48 | def __iter__(self): 49 | return iter(self.dict) 50 | 51 | def __len__(self): 52 | return len(self.dict) 53 | -------------------------------------------------------------------------------- /runbot/migrations/15.0.5.2/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("DROP TRIGGER IF EXISTS runbot_new_logging ON ir_logging") 3 | cr.execute("DROP FUNCTION IF EXISTS runbot_set_logging_build") 4 | -------------------------------------------------------------------------------- /runbot/migrations/15.0.5.3/post-migration.py: -------------------------------------------------------------------------------- 1 | from odoo import api, SUPERUSER_ID 2 | 3 | 4 | def migrate(cr, version): 5 | env = api.Environment(cr, SUPERUSER_ID, {}) 6 | projects = env['runbot.project'].search([]) 7 | for project in projects: 8 | if not project.master_bundle_id: 9 | master = env['runbot.bundle'].search([('name', '=', 'master'), ('project_id', '=', project.id)], limit=1) 10 | if not master: 11 | master = env['runbot.bundle'].create({ 12 | 'name': 'master', 13 | 'project_id': project.id, 14 | 'is_base': True, 15 | }) 16 | project.master_bundle_id = master 17 | 18 | if not project.dummy_bundle_id: 19 | dummy = env['runbot.bundle'].search([('name', '=', 'Dummy'), ('project_id', '=', project.id)], limit=1) 20 | if not dummy: 21 | dummy = env['runbot.bundle'].create({ 22 | 'name': 'Dummy', 23 | 'project_id': project.id, 24 | 'no_build': True, 25 | }) 26 | else: 27 | dummy.no_build = True 28 | project.dummy_bundle_id = dummy 29 | -------------------------------------------------------------------------------- /runbot/migrations/15.0.5.3/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("DELETE FROM ir_model_data WHERE module = 'runbot' AND model = 'runbot.bundle' and name in ('bundle_master', 'bundle_dummy')") 3 | -------------------------------------------------------------------------------- /runbot/migrations/16.0.5.4/post-migration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from odoo import api, SUPERUSER_ID 4 | 5 | _logger = logging.getLogger(__name__) 6 | 7 | 8 | def migrate(cr, version): 9 | env = api.Environment(cr, SUPERUSER_ID, {}) 10 | private = [ 11 | 'set_hook_time', 12 | 'set_ref_time', 13 | 'check_token', 14 | 'get_version_domain', 15 | 'get_builds', 16 | 'get_build_domain', 17 | 'disable', 18 | 'set_psql_conn_count', 19 | 'get_running_max', 20 | 'branch_groups', 21 | 'consistency_warning', 22 | 'fa_link_type', 23 | 'make_python_ctx', 24 | 'parse_config', 25 | 'get_color_class', 26 | 'get_formated_build_time', 27 | 'filter_patterns', 28 | 'http_log_url', 29 | 'result_multi', 30 | 'match_is_base', 31 | 'link_errors', 32 | 'clean_content', 33 | 'test_tags_list', 34 | 'disabling_tags', 35 | 'step_ids', 36 | 'recompute_infos', 37 | 'warning', 38 | 'is_file', 39 | ] 40 | removed = [ 41 | "get_formated_build_age", 42 | "get_formated_job_time", 43 | "make_dirs", 44 | "build_type_label", 45 | ] 46 | for method in private: 47 | pattern = f'.{method}(' 48 | replacepattern = f'._{method}(' 49 | views = env['ir.ui.view'].search([('arch_db', 'like', pattern)]) 50 | if views: 51 | _logger.info(f'Some views contains "{pattern}": {views}') 52 | for view in views: 53 | view.arch_db = view.arch_db.replace(pattern, replacepattern) 54 | 55 | for method in removed: 56 | pattern = f'.{method}(' 57 | views = env['ir.ui.view'].search([('arch_db', 'like', pattern)]) 58 | if views: 59 | _logger.error(f'Some views contains "{pattern}": {views}') 60 | 61 | for method in removed: 62 | pattern = f'.{method}(' 63 | steps =env['runbot.build.config.step'].search(['|', ('python_code', 'like', pattern), ('python_result_code', 'like', pattern)]) 64 | if steps: 65 | _logger.error(f'Some step contains "{pattern}": {steps}') 66 | 67 | for method in private: 68 | pattern = f'.{method}(' 69 | replacepattern = f'._{method}(' 70 | steps = env['runbot.build.config.step'].search(['|', ('python_code', 'like', pattern), ('python_result_code', 'like', pattern)]) 71 | for step in steps: 72 | python_code = pattern in step.python_code 73 | python_result_code = pattern in step.python_result_code 74 | if replacepattern not in step.python_code and python_code: 75 | _logger.warning(f'Some step python_code contains "{pattern}": {step}') 76 | python_code = False 77 | if replacepattern not in step.python_result_code and python_result_code: 78 | _logger.warning(f'Some step python_result_code contains "{pattern}": {step}') 79 | python_result_code = False 80 | 81 | if python_code or python_result_code: 82 | _logger.info(f'Some step python_code contains "{pattern}": {step} but looks like it was adapted') 83 | -------------------------------------------------------------------------------- /runbot/migrations/16.0.5.5/post-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute( 3 | """ 4 | INSERT INTO runbot_build_error_link(build_id,build_error_id,log_date) 5 | SELECT runbot_build_id,runbot_build_error_id,runbot_build.create_date as create_date 6 | FROM runbot_build_error_ids_runbot_build_rel 7 | LEFT JOIN runbot_build ON runbot_build.id = runbot_build_id; 8 | """) 9 | -------------------------------------------------------------------------------- /runbot/migrations/17.0.5.5/pre-migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | try: 6 | from odoo.upgrade import util 7 | except ImportError: 8 | util = None 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | def migrate(cr, version): 13 | if util: 14 | util.remove_field(cr, "runbot.build.config", "message_main_attachment_id") 15 | util.remove_field(cr, "runbot.build.config.step", "message_main_attachment_id") 16 | util.remove_field(cr, "runbot.build.error", "message_main_attachment_id") 17 | util.remove_field(cr, "runbot.error.regex", "message_main_attachment_id") 18 | util.remove_field(cr, "runbot.bundle", "message_main_attachment_id") 19 | util.remove_field(cr, "runbot.codeowner", "message_main_attachment_id") 20 | util.remove_field(cr, "runbot.dockerfile", "message_main_attachment_id") 21 | util.remove_field(cr, "runbot.host", "message_main_attachment_id") 22 | util.remove_field(cr, "runbot.trigger", "message_main_attachment_id") 23 | util.remove_field(cr, "runbot.remote", "message_main_attachment_id") 24 | util.remove_field(cr, "runbot.repo", "message_main_attachment_id") 25 | util.remove_field(cr, "runbot.team", "message_main_attachment_id") 26 | else: 27 | _logger.error('Missing utils, cannot migrate to 17.0') -------------------------------------------------------------------------------- /runbot/migrations/17.0.5.6/pre-migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | 6 | def migrate(cr, version): 7 | cr.execute('ALTER TABLE runbot_build ADD COLUMN create_batch_id INT') 8 | -------------------------------------------------------------------------------- /runbot/migrations/17.0.5.7/post-migration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from markupsafe import Markup 4 | 5 | from odoo import api, SUPERUSER_ID 6 | 7 | _logger = logging.getLogger(__name__) 8 | 9 | 10 | def migrate(cr, version): 11 | env = api.Environment(cr, SUPERUSER_ID, {}) 12 | dockerfiles = env['runbot.dockerfile'].search([]) 13 | for dockerfile in dockerfiles: 14 | if dockerfile.template_id and not dockerfile.layer_ids: 15 | dockerfile._template_to_layers() 16 | 17 | for dockerfile in dockerfiles: 18 | if dockerfile.template_id and dockerfile.layer_ids: 19 | dockerfile.message_post( 20 | body=Markup('Was using template %s') % (dockerfile.template_id.id, dockerfile.template_id.name) 21 | ) 22 | dockerfile.template_id = False 23 | -------------------------------------------------------------------------------- /runbot/migrations/17.0.5.7/pre-migration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from markupsafe import Markup 4 | 5 | from odoo import api, SUPERUSER_ID 6 | 7 | _logger = logging.getLogger(__name__) 8 | 9 | 10 | def migrate(cr, version): 11 | cr.execute("""DELETE FROM ir_model_data WHERE module='runbot' AND name = 'docker_base' RETURNING res_id""") 12 | res_id = cr.fetchone()[0] 13 | cr.execute("""UPDATE ir_ui_view SET key='runbot.docker_base' WHERE id = %s""", [res_id]) 14 | -------------------------------------------------------------------------------- /runbot/migrations/17.0.5.8/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute('ALTER TABLE runbot_build_error RENAME TO runbot_build_error_content') 3 | cr.execute('CREATE SEQUENCE runbot_build_error_content_id_seq') 4 | cr.execute("SELECT setval('runbot_build_error_content_id_seq', (SELECT MAX(id) FROM runbot_build_error_content))") 5 | cr.execute("ALTER TABLE runbot_build_error_content ALTER COLUMN id SET DEFAULT nextval('runbot_build_error_content_id_seq')") 6 | cr.execute('ALTER TABLE runbot_build_error_content ADD COLUMN first_seen_build_id INT') 7 | cr.execute('ALTER TABLE runbot_build_error_link RENAME COLUMN build_error_id TO error_content_id') 8 | -------------------------------------------------------------------------------- /runbot/migrations/18.0.5.10/post-migration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | _logger = logging.getLogger(__name__) 4 | 5 | def migrate(cr, version): 6 | cr.execute(""" 7 | UPDATE runbot_dockerfile 8 | SET image_identifier = subq.identifier, image_future_identifier = subq.identifier 9 | FROM (SELECT DISTINCT ON (dockerfile_id) dockerfile_id, identifier 10 | FROM runbot_docker_build_result 11 | WHERE result='success' order by dockerfile_id, create_date desc) 12 | AS subq 13 | WHERE runbot_dockerfile.id = subq.dockerfile_id; 14 | """) 15 | -------------------------------------------------------------------------------- /runbot/migrations/18.0.5.9/post-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute(""" 3 | WITH helper AS 4 | ( 5 | SELECT v.id, 6 | ( 7 | SELECT v2.id 8 | FROM runbot_version v2 9 | WHERE Coalesce(v2.SEQUENCE, 9999) <= Coalesce(v.SEQUENCE, 9999) 10 | AND v2.number < v.number 11 | ORDER BY v2.SEQUENCE DESC, 12 | v2.number DESC limit 1 ) AS v_excluded 13 | FROM runbot_version v 14 | ORDER BY v.SEQUENCE DESC, 15 | v.NUMBER DESC ) 16 | UPDATE runbot_build_error 17 | SET tags_min_version_excluded_id = h.v_excluded 18 | FROM helper h 19 | WHERE h.id = tags_min_version_id; 20 | """) 21 | cr.execute("""ALTER TABLE runbot_build_error DROP COLUMN tags_min_version_id;""") 22 | -------------------------------------------------------------------------------- /runbot/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import batch 4 | from . import branch 5 | from . import build 6 | from . import build_config 7 | from . import build_config_codeowner 8 | from . import build_error 9 | from . import build_error_merge 10 | from . import bundle 11 | from . import codeowner 12 | from . import commit 13 | from . import custom_trigger 14 | from . import database 15 | from . import docker 16 | from . import host 17 | from . import ir_cron 18 | from . import ir_http 19 | from . import ir_model_fields_converter 20 | from . import ir_qweb 21 | from . import ir_logging 22 | from . import project 23 | from . import repo 24 | from . import res_config_settings 25 | from . import res_users 26 | from . import runbot 27 | from . import team 28 | from . import upgrade 29 | from . import user 30 | from . import version 31 | from . import website 32 | 33 | # those imports have to be at the end otherwise the sql view cannot be initialised 34 | from . import build_stat 35 | from . import build_stat_regex 36 | -------------------------------------------------------------------------------- /runbot/models/build_stat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from odoo import models, fields, api, tools 4 | from ..fields import JsonDictField 5 | 6 | _logger = logging.getLogger(__name__) 7 | 8 | 9 | class BuildStat(models.Model): 10 | _name = "runbot.build.stat" 11 | _description = "Statistics" 12 | _log_access = False 13 | 14 | _sql_constraints = [ 15 | ( 16 | "build_config_key_unique", 17 | "unique (build_id, config_step_id, category)", 18 | "Build stats must be unique for the same build step", 19 | ) 20 | ] 21 | 22 | build_id = fields.Many2one("runbot.build", "Build", index=True, ondelete="cascade") 23 | config_step_id = fields.Many2one( 24 | "runbot.build.config.step", "Step", ondelete="cascade" 25 | ) 26 | category = fields.Char("Category", index=True) 27 | values = JsonDictField("Value") 28 | -------------------------------------------------------------------------------- /runbot/models/build_stat_regex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from ..common import os 5 | import re 6 | 7 | from odoo import models, fields, api 8 | from odoo.exceptions import ValidationError 9 | from odoo.tools import file_open 10 | 11 | VALUE_PATTERN = r"\(\?P\.+\)" # used to verify value group pattern 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | 16 | class BuildStatRegex(models.Model): 17 | """ A regular expression to extract a float/int value from a log file 18 | The regulare should contain a named group like '(?P.+)'. 19 | The result will be a key/value like {name: value} 20 | A second named group '(?P.+)' can bu used to augment the key name 21 | like {name.key_result: value} 22 | A 'generic' regex will be used when no regex are defined on a make_stat 23 | step. 24 | """ 25 | 26 | _name = "runbot.build.stat.regex" 27 | _description = "Statistics regex" 28 | _order = 'sequence,id' 29 | 30 | name = fields.Char("Key Name") 31 | regex = fields.Char("Regular Expression") 32 | description = fields.Char("Description") 33 | generic = fields.Boolean('Generic', help='Executed when no regex on the step', default=True) 34 | config_step_ids = fields.Many2many('runbot.build.config.step', string='Config Steps') 35 | sequence = fields.Integer('Sequence') 36 | 37 | @api.constrains("name", "regex") 38 | def _check_regex(self): 39 | for rec in self: 40 | try: 41 | r = re.compile(rec.regex) 42 | except re.error as e: 43 | raise ValidationError("Unable to compile regular expression: %s" % e) 44 | # verify that a named group exist in the pattern 45 | if not re.search(VALUE_PATTERN, r.pattern): 46 | raise ValidationError( 47 | "The regular expresion should contain the name group pattern 'value' e.g: '(?P.+)'" 48 | ) 49 | 50 | def _find_in_file(self, file_path): 51 | """ Search file regexes and write stats 52 | returns a dict of key:values 53 | """ 54 | if not os.path.exists(file_path): 55 | return {} 56 | stats_matches = {} 57 | with file_open(file_path, "r") as log_file: 58 | data = log_file.read() 59 | for build_stat_regex in self: 60 | current_stat_matches = {} 61 | for match in re.finditer(build_stat_regex.regex, data): 62 | group_dict = match.groupdict() 63 | try: 64 | value = float(group_dict.get("value")) 65 | except ValueError: 66 | _logger.warning( 67 | 'The matched value (%s) of "%s" cannot be converted into float', 68 | group_dict.get("value"), build_stat_regex.regex 69 | ) 70 | continue 71 | current_stat_matches[group_dict.get('key', 'value')] = value 72 | stats_matches[build_stat_regex.name] = current_stat_matches 73 | return stats_matches 74 | -------------------------------------------------------------------------------- /runbot/models/codeowner.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import re 3 | 4 | from odoo import models, fields, api 5 | from odoo.exceptions import ValidationError 6 | 7 | 8 | class Codeowner(models.Model): 9 | _name = 'runbot.codeowner' 10 | _description = "Codeowner regex" 11 | _inherit = "mail.thread" 12 | 13 | project_id = fields.Many2one('runbot.project', required=True, default=lambda self: self.env.ref('runbot.main_project', raise_if_not_found=False)) 14 | regex = fields.Char('Regular Expression', help='Regex to match full file paths', required=True, tracking=True) 15 | github_teams = fields.Char(help='Comma separated list of github teams to notify', tracking=True) 16 | team_id = fields.Many2one('runbot.team', help='Not mandatory runbot team', tracking=True) 17 | version_domain = fields.Char('Version Domain', help='Codeowner only applies to the filtered versions') 18 | organisation = fields.Char('organisation', related='project_id.organisation') 19 | 20 | @api.constrains('github_teams', 'team_id') 21 | def _check_team(self): 22 | for codeowner in self: 23 | if not codeowner.team_id and not codeowner.github_teams: 24 | raise ValidationError('Codeowner should at least have a runbot team or a github team') 25 | if codeowner.team_id and not codeowner.team_id.github_team: 26 | raise ValidationError('Team %s should have a github team defined to be used in codeowner' % codeowner.team_id.name) 27 | 28 | @api.constrains('regex') 29 | def _validate_regex(self): 30 | for rec in self: 31 | try: 32 | re.compile(rec.regex) 33 | except re.error as e: 34 | raise ValidationError("Unable to compile regular expression: %s" % e) 35 | 36 | @api.constrains('version_domain') 37 | def _validate_version_domain(self): 38 | for rec in self: 39 | try: 40 | self._match_version(self.env['runbot.version'].search([], limit=1)[0]) 41 | except Exception as e: 42 | raise ValidationError("Unable to validate version_domain: %s" % e) 43 | 44 | def _get_github_teams(self): 45 | github_teams = [] 46 | if self.github_teams: 47 | github_teams = self.github_teams.split(',') 48 | if self.team_id.github_team: 49 | github_teams.append(self.team_id.github_team) 50 | return github_teams 51 | 52 | def _get_version_domain(self): 53 | """ Helper to get the evaluated version domain """ 54 | self.ensure_one() 55 | return ast.literal_eval(self.version_domain) if self.version_domain else [] 56 | 57 | def _match_version(self, version): 58 | return not self.version_domain or version.filtered_domain(self._get_version_domain()) 59 | -------------------------------------------------------------------------------- /runbot/models/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from odoo import models, fields, api 3 | _logger = logging.getLogger(__name__) 4 | 5 | 6 | class Database(models.Model): 7 | _name = 'runbot.database' 8 | _description = "Database" 9 | 10 | name = fields.Char('Host name', required=True) 11 | build_id = fields.Many2one('runbot.build', index=True, required=True) 12 | db_suffix = fields.Char(compute='_compute_db_suffix') 13 | 14 | def _compute_db_suffix(self): 15 | for record in self: 16 | record.db_suffix = record.name.replace('%s-' % record.build_id.dest, '') 17 | 18 | @api.model_create_multi 19 | def create(self, vals_list): 20 | records = self.browse() 21 | for vals in vals_list: 22 | res = self.search([('name', '=', vals['name']), ('build_id', '=', vals['build_id'])]) 23 | if res: 24 | records |= res 25 | else: 26 | records |= super().create(vals) 27 | -------------------------------------------------------------------------------- /runbot/models/ir_cron.py: -------------------------------------------------------------------------------- 1 | import odoo 2 | from dateutil.relativedelta import relativedelta 3 | 4 | from odoo import models, fields 5 | 6 | odoo.service.server.SLEEP_INTERVAL = 5 7 | odoo.addons.base.models.ir_cron._intervalTypes['seconds'] = lambda interval: relativedelta(seconds=interval) 8 | 9 | 10 | class ir_cron(models.Model): 11 | _inherit = "ir.cron" 12 | 13 | interval_type = fields.Selection(selection_add=[('seconds', 'Seconds')], ondelete={'seconds': 'cascade'}) 14 | -------------------------------------------------------------------------------- /runbot/models/ir_http.py: -------------------------------------------------------------------------------- 1 | from odoo import models 2 | from odoo.http import request 3 | import threading 4 | 5 | 6 | class IrHttp(models.AbstractModel): 7 | _inherit = ["ir.http"] 8 | 9 | @classmethod 10 | def _dispatch(cls, endpoint): 11 | result = super()._dispatch(endpoint) 12 | if request: 13 | threading.current_thread().user_name = request.env.user.name 14 | return result 15 | -------------------------------------------------------------------------------- /runbot/models/ir_model_fields_converter.py: -------------------------------------------------------------------------------- 1 | from odoo import models 2 | 3 | class IrFieldsConverter(models.AbstractModel): 4 | _inherit = 'ir.fields.converter' 5 | 6 | def _str_to_jsonb(self, model, field, value): 7 | return self._str_to_json(model, field, value) 8 | -------------------------------------------------------------------------------- /runbot/models/ir_qweb.py: -------------------------------------------------------------------------------- 1 | from ..common import s2human, s2human_long 2 | from odoo import models 3 | from odoo.http import request 4 | 5 | 6 | class IrQweb(models.AbstractModel): 7 | _inherit = ["ir.qweb"] 8 | 9 | def _prepare_frontend_environment(self, values): 10 | response = super()._prepare_frontend_environment(values) 11 | values['s2human'] = s2human 12 | values['s2human_long'] = s2human_long 13 | return response 14 | -------------------------------------------------------------------------------- /runbot/models/module.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/models/module.py -------------------------------------------------------------------------------- /runbot/models/project.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields, api 2 | from odoo.exceptions import ValidationError 3 | 4 | 5 | class Project(models.Model): 6 | _name = 'runbot.project' 7 | _description = 'Project' 8 | _order = 'sequence, id' 9 | 10 | name = fields.Char('Project name', required=True) 11 | group_ids = fields.Many2many('res.groups', string='Required groups') 12 | keep_sticky_running = fields.Boolean('Keep last sticky builds running') 13 | trigger_ids = fields.One2many('runbot.trigger', 'project_id', string='Triggers') 14 | dockerfile_id = fields.Many2one('runbot.dockerfile', index=True, help="Project Default Dockerfile") 15 | repo_ids = fields.One2many('runbot.repo', 'project_id', string='Repos') 16 | sequence = fields.Integer('Sequence') 17 | organisation = fields.Char('organisation', default=lambda self: self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_organisation')) 18 | token = fields.Char("Github token", groups="runbot.group_runbot_admin") 19 | master_bundle_id = fields.Many2one('runbot.bundle', string='Master bundle') 20 | dummy_bundle_id = fields.Many2one('runbot.bundle', string='Dummy bundle') 21 | always_use_foreign = fields.Boolean('Use foreign bundle', help='By default, check for the same bundle name in another project to fill missing commits.', default=False) 22 | tmp_prefix = fields.Char('tmp branches prefix', default="tmp.") 23 | staging_prefix = fields.Char('staging branches prefix', default="staging.") 24 | hidden = fields.Boolean('Hidden', help='Hide this project from the main page') 25 | active = fields.Boolean("Active", default=True) 26 | process_delay = fields.Integer('Process delay', default=60, required=True, help="Delay between a push and a batch starting its process.") 27 | 28 | @api.constrains('process_delay') 29 | def _constraint_process_delay(self): 30 | if any(project.process_delay < 0 for project in self): 31 | raise ValidationError("Process delay should be positive.") 32 | 33 | @api.model_create_multi 34 | def create(self, vals_list): 35 | projects = super().create(vals_list) 36 | base_bundle_values = [] 37 | dummy_bundle_values = [] 38 | for project in projects: 39 | base_bundle_values.append({ 40 | 'project_id': project.id, 41 | 'name': 'master', 42 | 'is_base': True, 43 | }) 44 | dummy_bundle_values.append({ 45 | 'project_id': project.id, 46 | 'name': 'Dummy', 47 | 'no_build': True, 48 | }) 49 | master_bundles = self.env['runbot.bundle'].create(base_bundle_values) 50 | dummy_bundles = self.env['runbot.bundle'].create(dummy_bundle_values) 51 | for project, bundle in zip(projects, master_bundles): 52 | project.master_bundle_id = bundle 53 | for project, bundle in zip(projects, dummy_bundles): 54 | project.dummy_bundle_id = bundle 55 | return projects 56 | 57 | 58 | class Category(models.Model): 59 | _name = 'runbot.category' 60 | _description = 'Trigger category' 61 | 62 | name = fields.Char("Name") 63 | icon = fields.Char("Font awesome icon") 64 | view_id = fields.Many2one('ir.ui.view', "Link template") 65 | active = fields.Boolean('active', default=True) 66 | -------------------------------------------------------------------------------- /runbot/models/res_users.py: -------------------------------------------------------------------------------- 1 | 2 | # Part of Odoo. See LICENSE file for full copyright and licensing details. 3 | 4 | from odoo import fields, models 5 | 6 | 7 | class ResUsers(models.Model): 8 | _inherit = 'res.users' 9 | 10 | runbot_team_ids = fields.Many2many('runbot.team', string="Runbot Teams") 11 | github_login = fields.Char('Github account') 12 | 13 | _sql_constraints = [ 14 | ( 15 | "github_login_unique", 16 | "unique (github_login)", 17 | "Github login can only belong to one user", 18 | ) 19 | ] 20 | 21 | @property 22 | def SELF_WRITEABLE_FIELDS(self): 23 | return super().SELF_WRITEABLE_FIELDS + ['github_login'] 24 | 25 | def write(self, values): 26 | if list(values.keys()) == ['github_login'] and self.env.user.has_group('runbot.group_runbot_team_manager'): 27 | return super(ResUsers, self.sudo()).write(values) 28 | return super().write(values) 29 | -------------------------------------------------------------------------------- /runbot/models/user.py: -------------------------------------------------------------------------------- 1 | 2 | from odoo import models, fields 3 | 4 | 5 | class User(models.Model): 6 | _inherit = 'res.users' 7 | 8 | # Add default action_id 9 | action_id = fields.Many2one('ir.actions.actions', 10 | default=lambda self: self.env.ref('runbot.open_view_warning_tree', raise_if_not_found=False)) 11 | -------------------------------------------------------------------------------- /runbot/models/website.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields, api 2 | from odoo.http import request 3 | from odoo.addons.web.controllers.utils import get_action_triples 4 | import re 5 | 6 | 7 | class Website(models.AbstractModel): 8 | _inherit = "website.seo.metadata" 9 | 10 | def get_website_meta(self): 11 | # this is kind of hacky but should improve user experience when sharing runbot links 12 | # right now, a backend link will lead to the login page creating a link preview of the login page. 13 | # this override will hopefully remove the website image and try to improve the meta based on the record 14 | # in the backend, if possible to extract 15 | res = super().get_website_meta() 16 | del res['opengraph_meta']['og:image'] 17 | del res['twitter_meta'] 18 | if request and request.params.get('redirect') and not request.params.get('login_success'): 19 | redirect = request.params['redirect'] 20 | if redirect.startswith('/odoo/'): 21 | try: 22 | actions = list(get_action_triples(self.env, redirect.split('?')[0].removeprefix('/odoo/'))) 23 | except ValueError: 24 | actions = None 25 | if actions: 26 | _active_id, action, record_id = actions[-1] 27 | model = action.res_model 28 | record = self.env[model] 29 | if record_id and model.startswith('runbot.'): 30 | record = self.env[model].browse(record_id).exists() 31 | if record.sudo(False)._check_access('read'): 32 | record = self.env[model] 33 | title = f'{record._description}' 34 | if record: 35 | title = f'{record._description} | {record.display_name}' 36 | if 'description' in record._fields: 37 | res['opengraph_meta']['og:description'] = record.description 38 | res['opengraph_meta']['og:title'] = title 39 | res['opengraph_meta']['og:url'] = request.httprequest.url_root.strip('/') + redirect 40 | return res -------------------------------------------------------------------------------- /runbot/security/ir.rule.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id/id,groups/id,domain_force,perm_read,perm_create,perm_write,perm_unlink 2 | 3 | 4 | rule_project,"limited to groups",model_runbot_project,group_user,"['|', ('group_ids', '=', False), ('group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 5 | rule_project_mgmt,"manager can see all",model_runbot_project,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 6 | 7 | rule_repo,"limited to groups",model_runbot_repo,group_user,"['|', ('project_id.group_ids', '=', False), ('project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 8 | rule_repo_mgmt,"manager can see all",model_runbot_repo,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 9 | rule_branch,"limited to groups",model_runbot_branch,group_user,"['|', ('remote_id.repo_id.project_id.group_ids', '=', False), ('remote_id.repo_id.project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 10 | rule_branch_mgmt,"manager can see all",model_runbot_branch,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 11 | rule_commit,"limited to groups",model_runbot_commit,group_user,"['|', ('repo_id.project_id.group_ids', '=', False), ('repo_id.project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 12 | rule_commit_mgmt,"manager can see all",model_runbot_commit,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 13 | rule_build,"limited to groups",model_runbot_build,group_user,"['|', ('params_id.project_id.group_ids', '=', False), ('params_id.project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 14 | rule_build_mgmt,"manager can see all",model_runbot_build,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 15 | -------------------------------------------------------------------------------- /runbot/static/src/img/icon_killed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/img/icon_killed.png -------------------------------------------------------------------------------- /runbot/static/src/img/icon_killed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /runbot/static/src/img/icon_ko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/img/icon_ko.png -------------------------------------------------------------------------------- /runbot/static/src/img/icon_ko.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /runbot/static/src/img/icon_ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/img/icon_ok.png -------------------------------------------------------------------------------- /runbot/static/src/img/icon_ok.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /runbot/static/src/img/icon_skipped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/img/icon_skipped.png -------------------------------------------------------------------------------- /runbot/static/src/img/icon_skipped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /runbot/static/src/img/icon_warn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/img/icon_warn.png -------------------------------------------------------------------------------- /runbot/static/src/img/icon_warn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /runbot/static/src/js/fields/fields.css: -------------------------------------------------------------------------------- 1 | .o_field_widget.o_field_runbotjsonb { 2 | width: 100%; 3 | white-space: pre-wrap; 4 | } 5 | -------------------------------------------------------------------------------- /runbot/static/src/js/fields/tracking_value.js: -------------------------------------------------------------------------------- 1 | /** @odoo-module **/ 2 | import { patch } from "@web/core/utils/patch"; 3 | import { Message } from "@mail/core/common/message"; 4 | import { diff_match_patch } from "@runbot/libs/diff_match_patch/diff_match_patch"; 5 | 6 | patch(Message.prototype, { 7 | setup() { 8 | super.setup(...arguments); 9 | this.kept = false; 10 | }, 11 | isMultiline(trackingValue) { 12 | const oldValue = trackingValue.oldValue.value; 13 | const newValue = trackingValue.newValue.value; 14 | return ((oldValue && typeof oldValue=== 'string' && oldValue.includes('\n')) && (newValue && typeof oldValue=== 'string' && newValue.includes('\n'))) 15 | }, 16 | formatTracking(trackingType, trackingValue) { 17 | return super.formatTracking(trackingType, trackingValue) 18 | }, 19 | toggleKept() { 20 | this.kept = !this.kept; 21 | }, 22 | copyOldToClipboard(trackingValue) { 23 | return function () { 24 | navigator.clipboard.writeText(trackingValue.oldValue.value); 25 | }; 26 | }, 27 | copyNewToClipboard(trackingValue) { 28 | return function () { 29 | navigator.clipboard.writeText(trackingValue.newValue.value); 30 | }; 31 | }, 32 | lines(trackingValue) { 33 | const oldValue = trackingValue.oldValue.value; 34 | const newValue = trackingValue.newValue.value; 35 | const diff = this.makeDiff(oldValue, newValue); 36 | const lines = this.prepareForRendering(diff); 37 | return lines; 38 | }, 39 | makeDiff(text1, text2) { 40 | var dmp = new diff_match_patch(); 41 | var a = dmp.diff_linesToChars_(text1, text2); 42 | var lineText1 = a.chars1; 43 | var lineText2 = a.chars2; 44 | var lineArray = a.lineArray; 45 | var diffs = dmp.diff_main(lineText1, lineText2, false); 46 | dmp.diff_charsToLines_(diffs, lineArray); 47 | dmp.diff_cleanupSemantic(diffs); 48 | return diffs; 49 | }, 50 | prepareForRendering(diffs) { 51 | var lines = []; 52 | var pre_line_counter = 0 53 | var post_line_counter = 0 54 | for (var x = 0; x < diffs.length; x++) { 55 | var diff_type = diffs[x][0]; 56 | var data = diffs[x][1]; 57 | var data_lines = data.split('\n'); 58 | for (var line_index in data_lines) { 59 | var line = data_lines[line_index]; 60 | line = line.replace(/&/g, '&'); 61 | line = line.replace(//g, '>'); 63 | //text = text.replace(/\n/g, ''); 64 | //text = text.replace(/ /g, '  '); 65 | if (diff_type == -1) { 66 | lines.push({type:'removed', pre_line_counter: pre_line_counter, post_line_counter: '-', line: line}) 67 | pre_line_counter += 1 68 | } else if (diff_type == 0) { 69 | lines.push({type:'kept', pre_line_counter: '', post_line_counter: post_line_counter, line: line}) 70 | pre_line_counter += 1 71 | post_line_counter +=1 72 | } else if (diff_type == 1) { 73 | lines.push({type:'added', pre_line_counter: '+', post_line_counter: post_line_counter, line: line}) 74 | post_line_counter +=1 75 | } 76 | } 77 | } 78 | return lines; 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /runbot/static/src/js/fields/tracking_value.scss: -------------------------------------------------------------------------------- 1 | .code_diff { 2 | white-space: nowrap; 3 | overflow-x: auto; 4 | font: 12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; 5 | 6 | .col_number { 7 | background-color: #EEE; 8 | } 9 | 10 | .added { 11 | background:#a1f1a1; 12 | } 13 | 14 | .removed { 15 | background:#f3a3a3; 16 | } 17 | 18 | .code { 19 | white-space: pre-wrap; 20 | } 21 | .diff_line { 22 | background-color: #FFF; 23 | } 24 | 25 | .diff_line:hover { 26 | filter: brightness(92%); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /runbot/static/src/js/fields/tracking_value.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Copy old value to clipboard 9 | Toggle context 10 | Copy new value to clipboard 11 | 12 | () 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | () 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /runbot/static/src/js/log_display.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/js/log_display.js -------------------------------------------------------------------------------- /runbot/static/src/js/runbot.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | "use strict"; 3 | $(function () { 4 | $(document).on('click', '[data-runbot]', function (e) { 5 | e.preventDefault(); 6 | var data = $(this).data(); 7 | var operation = data.runbot; 8 | if (!operation) { 9 | return; 10 | } 11 | var xhr = new XMLHttpRequest(); 12 | var url = e.target.href 13 | if (data.runbotBuild) { 14 | url = '/runbot/build/' + data.runbotBuild + '/' + operation 15 | } 16 | var elem = e.target 17 | xhr.addEventListener('load', function () { 18 | if (operation == 'rebuild' && window.location.href.split('?')[0].endsWith('/build/' + data.runbotBuild)){ 19 | window.location.href = window.location.href.replace('/build/' + data.runbotBuild, '/build/' + xhr.responseText); 20 | } else if (operation == 'action') { 21 | elem.parentElement.innerText = this.responseText 22 | } else { 23 | window.location.reload(); 24 | } 25 | }); 26 | xhr.open('POST', url); 27 | xhr.send(); 28 | }); 29 | }); 30 | })(jQuery); 31 | 32 | 33 | function copyToClipboard(text) { 34 | if (!navigator.clipboard) { 35 | console.error('Clipboard not supported'); 36 | return; 37 | } 38 | navigator.clipboard.writeText(text); 39 | } 40 | -------------------------------------------------------------------------------- /runbot/static/src/js/views/form_controller.js: -------------------------------------------------------------------------------- 1 | /** @odoo-module **/ 2 | 3 | import { FormController } from '@web/views/form/form_controller'; 4 | import { patch } from '@web/core/utils/patch'; 5 | 6 | 7 | patch(FormController.prototype, { 8 | // Prevent saving on tab switching 9 | beforeVisibilityChange: () => {}, 10 | // Prevent closing page with dirty fields 11 | async beforeUnload(ev) { 12 | if (await this.model.root.isDirty()) { 13 | ev.preventDefault(); 14 | ev.returnValue = 'Unsaved changes'; 15 | } else { 16 | super.beforeUnload(ev); 17 | } 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /runbot/static/src/libs/bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2019 Twitter, Inc. 4 | Copyright (c) 2011-2019 The Bootstrap Authors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /runbot/static/src/libs/bootstrap/js/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * Bootstrap (v4.3.1): index.js 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | * -------------------------------------------------------------------------- 6 | */ 7 | (function ($) { 8 | if (typeof $ === 'undefined') { 9 | throw new TypeError('Bootstrap\'s JavaScript requires jQuery. jQuery must be included before Bootstrap\'s JavaScript.'); 10 | } 11 | 12 | var version = $.fn.jquery.split(' ')[0].split('.'); 13 | var minMajor = 1; 14 | var ltMajor = 2; 15 | var minMinor = 9; 16 | var minPatch = 1; 17 | var maxMajor = 4; 18 | 19 | if (version[0] < ltMajor && version[1] < minMinor || version[0] === minMajor && version[1] === minMinor && version[2] < minPatch || version[0] >= maxMajor) { 20 | throw new Error('Bootstrap\'s JavaScript requires at least jQuery v1.9.1 but less than v4.0.0'); 21 | } 22 | })($); 23 | -------------------------------------------------------------------------------- /runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odoo/runbot/5ad18883104bc16909e2c9d13d972775f32706de/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /runbot/static/src/libs/jquery/jquery.browser.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | /** reimport deprecated $.browser, remove me when jquery.ba-bqq is dropped */ 3 | $.uaMatch = function( ua ) { 4 | var ua = ua.toLowerCase(); 5 | 6 | var match = /(chrome)[ \/]([\w.]+)/.exec( ua ) || 7 | /(webkit)[ \/]([\w.]+)/.exec( ua ) || 8 | /(opera)(?:.*version|)[ \/]([\w.]+)/.exec( ua ) || 9 | /(msie) ([\w.]+)/.exec( ua ) || 10 | ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec( ua ) || 11 | []; 12 | 13 | return { 14 | browser: match[ 1 ] || "", 15 | version: match[ 2 ] || "0" 16 | }; 17 | }; 18 | // Don't clobber any existing jQuery.browser in case it's different 19 | if ( !$.browser ) { 20 | var matched = $.uaMatch( navigator.userAgent ); 21 | var browser = {}; 22 | 23 | if ( matched.browser ) { 24 | browser[ matched.browser ] = true; 25 | browser.version = matched.version; 26 | } 27 | 28 | // Chrome is Webkit, but Webkit is also Safari. 29 | if ( browser.chrome ) { 30 | browser.webkit = true; 31 | } else if ( browser.webkit ) { 32 | browser.safari = true; 33 | } 34 | 35 | $.browser = browser; 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /runbot/templates/badge.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <?xml version="1.0"?> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | <?xml version="1.0"?> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /runbot/templates/dockerfile.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /runbot/templates/error_merge.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Error Merge 7 | Error merge rule: () 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | # 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /runbot/templates/git.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [core] 5 | repositoryformatversion = 0 6 | filemode = true 7 | bare = true 8 | 9 | [remote ""] 10 | url = 11 | fetch = +refs/heads/*:refs//heads/* 12 | fetch = +refs/pull/*/head:refs//pull/* 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /runbot/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import common 2 | from . import test_batch 3 | from . import test_repo 4 | from . import test_build_error 5 | from . import test_branch 6 | from . import test_build 7 | from . import test_schedule 8 | from . import test_cron 9 | from . import test_build_config_step 10 | from . import test_event 11 | from . import test_command 12 | from . import test_build_stat 13 | from . import test_version 14 | from . import test_runbot 15 | from . import test_commit 16 | from . import test_upgrade 17 | from . import test_dockerfile 18 | from . import test_host 19 | -------------------------------------------------------------------------------- /runbot/tests/test_batch.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from odoo import fields 4 | 5 | from .common import RunbotCase 6 | 7 | 8 | class TestBatch(RunbotCase): 9 | 10 | def test_process_delay(self): 11 | self.project.process_delay = 120 12 | self.additionnal_setup() 13 | 14 | batch = self.branch_addons.bundle_id.last_batch 15 | batch._process() 16 | self.assertEqual(batch.state, 'preparing') 17 | 18 | batch.last_update = fields.Datetime.now() - timedelta(seconds=120) 19 | batch._process() 20 | self.assertEqual(batch.state, 'ready') 21 | -------------------------------------------------------------------------------- /runbot/tests/test_command.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from odoo.tests import common 3 | from ..container import Command 4 | from ..container import sanitize_container_name 5 | 6 | 7 | CONFIG = """[options] 8 | foo = bar 9 | """ 10 | 11 | 12 | class Test_Command(common.TransactionCase): 13 | 14 | def test_command(self): 15 | cmd = Command([], ['python3', 'odoo-bin', '--test-tags', "-.test_js[core > utils,core > display]"], []) 16 | self.assertEqual(cmd.build(), 'python3 odoo-bin --test-tags "-.test_js[core > utils,core > display]"') 17 | 18 | cmd = Command([], ['psql', '-c', '"SQL query"', '>', 'some_file'], []) 19 | self.assertEqual(cmd.build(), 'psql -c "SQL query" > some_file') 20 | 21 | 22 | pres = ['pip3', 'install', 'foo'] 23 | posts = ['python3', '-m', 'coverage', 'html'] 24 | finals = ['pgdump', 'bar'] 25 | cmd = Command([pres], ['python3', 'odoo-bin'], [posts], finals=[finals]) 26 | self.assertEqual(str(cmd), 'python3 odoo-bin') 27 | 28 | expected = 'pip3 install foo && python3 odoo-bin && python3 -m coverage html ; pgdump bar' 29 | self.assertEqual(cmd.build(), expected) 30 | 31 | cmd = Command([pres], ['python3', 'odoo-bin'], [posts]) 32 | cmd.add_config_tuple('a', 'b') 33 | cmd += ['bar'] 34 | self.assertIn('bar', cmd.cmd) 35 | cmd.add_config_tuple('x', 'y') 36 | 37 | content = cmd.get_config(starting_config=CONFIG) 38 | 39 | self.assertIn('[options]', content) 40 | self.assertIn('foo = bar', content) 41 | self.assertIn('a = b', content) 42 | self.assertIn('x = y', content) 43 | 44 | 45 | 46 | 47 | with self.assertRaises(AssertionError): 48 | cmd.add_config_tuple('http-interface', '127.0.0.1') 49 | 50 | 51 | class TestSanitizeContainerName(common.TransactionCase): 52 | 53 | def test_sanitize_container_name(self): 54 | 55 | # 1. test that a valid name remains unchanged 56 | valid_name = '3155889-saas-13.4-container-all_at_install' 57 | self.assertEqual(sanitize_container_name(valid_name), valid_name) 58 | 59 | # 2. test a name starting with an invalid character 60 | invalid_name = '#3155889-saas-13.4-container-all_at_install' 61 | self.assertEqual(sanitize_container_name(invalid_name), valid_name) 62 | 63 | # 3. test a name with an invalid character somewhere 64 | invalid_name = '3155889-saas-13.4-container#-all_at_install' 65 | self.assertEqual(sanitize_container_name(invalid_name), valid_name) 66 | 67 | # 4. test a name starting with multiple invalid characters 68 | invalid_name = '#/.3155889-saas-13.4-container-all_at_install' 69 | self.assertEqual(sanitize_container_name(invalid_name), valid_name) 70 | 71 | # 5. test both 72 | invalid_name = '_.3155889-saas-13.4-##container/-all_at_install' 73 | self.assertEqual(sanitize_container_name(invalid_name), valid_name) 74 | -------------------------------------------------------------------------------- /runbot/tests/test_cron.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest.mock import patch 3 | from .common import RunbotCase 4 | 5 | 6 | class SleepException(Exception): 7 | ... 8 | 9 | 10 | def sleep(time): 11 | raise SleepException() 12 | 13 | 14 | class TestCron(RunbotCase): 15 | 16 | def setUp(self): 17 | super(TestCron, self).setUp() 18 | self.start_patcher('list_local_dbs_patcher', 'odoo.addons.runbot.models.host.list_local_dbs', ['runbot_logs']) 19 | self.start_patcher('_get_cron_period', 'odoo.addons.runbot.models.runbot.Runbot._get_cron_period', 2) 20 | 21 | @patch('time.sleep', side_effect=sleep) 22 | @patch('odoo.addons.runbot.models.repo.Repo._update_batches') 23 | def test_cron_schedule(self, mock_update_batches, *args): 24 | """ test that cron_fetch_and_schedule do its work """ 25 | self.env['ir.config_parameter'].sudo().set_param('runbot.runbot_update_frequency', 1) 26 | self.env['ir.config_parameter'].sudo().set_param('runbot.runbot_do_fetch', True) 27 | self.env['runbot.repo'].search([('id', '!=', self.repo_server.id)]).write({'mode': 'disabled'}) # disable all other existing repo than repo_server 28 | try: 29 | self.Runbot._cron() 30 | except SleepException: 31 | pass # sleep raises an exception to avoid to stay stuck in loop 32 | mock_update_batches.assert_called() 33 | 34 | @patch('time.sleep', side_effect=sleep) 35 | @patch('odoo.addons.runbot.models.host.Host._docker_update_images') 36 | @patch('odoo.addons.runbot.models.host.Host._bootstrap') 37 | @patch('odoo.addons.runbot.models.runbot.Runbot._scheduler') 38 | def test_cron_build(self, mock_scheduler, mock_host_bootstrap, mock_host_docker_update_images, *args): 39 | """ test that cron_fetch_and_build do its work """ 40 | hostname = 'cronhost.runbot.com' 41 | self.patchers['hostname_patcher'].return_value = hostname 42 | self.env['ir.config_parameter'].sudo().set_param('runbot.runbot_update_frequency', 1) 43 | self.env['ir.config_parameter'].sudo().set_param('runbot.runbot_do_schedule', True) 44 | self.env['runbot.repo'].search([('id', '!=', self.repo_server.id)]).write({'mode': 'disabled'}) # disable all other existing repo than repo_server 45 | 46 | try: 47 | self.Runbot._cron() 48 | except SleepException: 49 | pass # sleep raises an exception to avoid to stay stuck in loop 50 | mock_scheduler.assert_called() 51 | mock_host_bootstrap.assert_called() 52 | mock_host_docker_update_images.assert_called() 53 | host = self.env['runbot.host'].search([('name', '=', hostname)]) 54 | self.assertTrue(host, 'A new host should have been created') 55 | # self.assertGreater(host.psql_conn_count, 0, 'A least one connection should exist on the current psql batch') 56 | -------------------------------------------------------------------------------- /runbot/tests/test_runbot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from .common import RunbotCase 5 | 6 | _logger = logging.getLogger(__name__) 7 | 8 | 9 | class TestRunbot(RunbotCase): 10 | 11 | def test_warning_from_runbot_abstract(self): 12 | warning = self.env['runbot.runbot']._warning('Test warning message') 13 | 14 | self.assertTrue(self.env['runbot.warning'].browse(warning.id).exists()) 15 | -------------------------------------------------------------------------------- /runbot/tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from unittest.mock import patch 4 | from .common import RunbotCase 5 | 6 | 7 | class TestSchedule(RunbotCase): 8 | 9 | @patch('odoo.addons.runbot.models.build.os.path.getmtime') 10 | @patch('odoo.addons.runbot.models.build.docker_state') 11 | def test_schedule_mark_done(self, mock_docker_state, mock_getmtime): 12 | """ Test that results are set even when job_30_run is skipped """ 13 | job_end_time = datetime.datetime.now() 14 | mock_getmtime.return_value = job_end_time.timestamp() # looks wrong 15 | 16 | params = self.BuildParameters.create({ 17 | 'version_id': self.version_13, 18 | 'project_id': self.project, 19 | 'config_id': self.env.ref('runbot.runbot_build_config_default').id, 20 | }) 21 | 22 | host = self.env['runbot.host'].create({'name': 'runbotxx'}) # the host needs to exists in _schedule() 23 | 24 | build = self.Build.create({ 25 | 'local_state': 'testing', 26 | 'global_state': 'testing', 27 | 'port': '1234', 28 | 'host': host.name, 29 | 'job_start': datetime.datetime.now(), 30 | 'active_step': self.env.ref('runbot.runbot_build_config_step_run').id, 31 | 'docker_start': datetime.datetime.now(), 32 | 'params_id': params.id, 33 | }) 34 | mock_docker_state.return_value = 'UNKNOWN' 35 | self.assertEqual(build.local_state, 'testing') 36 | build._schedule() # too fast, docker not started 37 | self.assertEqual(build.local_state, 'testing') 38 | self.assertEqual(build.local_result, 'ok') 39 | 40 | self.start_patcher('fetch_local_logs', 'odoo.addons.runbot.models.host.Host._fetch_local_logs', []) # the local logs have to be empty 41 | build.write({'docker_start': datetime.datetime.now() - datetime.timedelta(seconds=70)}) # docker never started 42 | build._schedule() 43 | self.assertEqual(build.local_state, 'done') 44 | self.assertEqual(build.local_result, 'ko') 45 | -------------------------------------------------------------------------------- /runbot/tests/test_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .common import RunbotCase 3 | 4 | 5 | class TestVersion(RunbotCase): 6 | 7 | def test_basic_version(self): 8 | 9 | major_version = self.Version._get('12.0') 10 | self.assertEqual(major_version.number, '12.00') 11 | self.assertTrue(major_version.is_major) 12 | 13 | saas_version = self.Version._get('saas-12.1') 14 | self.assertEqual(saas_version.number, '12.01') 15 | self.assertFalse(saas_version.is_major) 16 | 17 | self.assertGreater(saas_version.number, major_version.number) 18 | 19 | master_version = self.Version._get('master') 20 | self.assertEqual(master_version.number, '~') 21 | self.assertGreater(master_version.number, saas_version.number) 22 | 23 | def test_version_relations(self): 24 | version = self.env['runbot.version'] 25 | v11 = version._get('11.0') 26 | v113 = version._get('saas-11.3') 27 | v12 = version._get('12.0') 28 | v122 = version._get('saas-12.2') 29 | v124 = version._get('saas-12.4') 30 | v13 = version._get('13.0') 31 | v131 = version._get('saas-13.1') 32 | v132 = version._get('saas-13.2') 33 | v133 = version._get('saas-13.3') 34 | master = version._get('master') 35 | 36 | self.assertEqual(v11.previous_major_version_id, version) 37 | self.assertEqual(v11.intermediate_version_ids, version) 38 | 39 | self.assertEqual(v113.previous_major_version_id, v11) 40 | self.assertEqual(v113.intermediate_version_ids, version) 41 | 42 | self.assertEqual(v12.previous_major_version_id, v11) 43 | self.assertEqual(v12.intermediate_version_ids, v113) 44 | 45 | self.assertEqual(v12.previous_major_version_id, v11) 46 | self.assertEqual(v12.intermediate_version_ids, v113) 47 | self.assertEqual(v12.next_major_version_id, v13) 48 | self.assertEqual(v12.next_intermediate_version_ids, v124 | v122) 49 | 50 | self.assertEqual(v13.previous_major_version_id, v12) 51 | self.assertEqual(v13.intermediate_version_ids, v124 | v122) 52 | self.assertEqual(v13.next_major_version_id, master) 53 | self.assertEqual(v13.next_intermediate_version_ids, v133 | v132 | v131) 54 | 55 | self.assertEqual(v132.previous_major_version_id, v13) 56 | self.assertEqual(v132.intermediate_version_ids, v131) 57 | self.assertEqual(v132.next_major_version_id, master) 58 | self.assertEqual(v132.next_intermediate_version_ids, v133) 59 | 60 | self.assertEqual(master.previous_major_version_id, v13) 61 | self.assertEqual(master.intermediate_version_ids, v133 | v132 | v131) 62 | 63 | def test_version_docker_file(self): 64 | version18 = self.env['runbot.version'].create({'name': '18.0'}) 65 | versionmaster = self.env['runbot.version'].search([('name', '=', 'master')]) 66 | self.assertEqual(version18.dockerfile_id, versionmaster.dockerfile_id) 67 | versionmaster.dockerfile_id = self.env['runbot.dockerfile'].create({'name': 'New dockefile for master'}) 68 | version181 = self.env['runbot.version'].create({'name': '18.1'}) 69 | self.assertEqual(version181.dockerfile_id, versionmaster.dockerfile_id) 70 | self.assertEqual(version181.dockerfile_id.name, 'New dockefile for master') 71 | -------------------------------------------------------------------------------- /runbot/views/branch_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot.branch.form 5 | runbot.branch 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | runbot.branch.list 34 | runbot.branch 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Branches 46 | runbot.branch 47 | list,form 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /runbot/views/build_error_link_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Runbot Build Error Link List 4 | runbot.build.error.link 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Runbot Build Error Link Search 21 | runbot.build.error.link 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Runbot Build Error Link 47 | runbot.build.error.link 48 | list,pivot,form 49 | 50 | 51 | -------------------------------------------------------------------------------- /runbot/views/build_error_merge_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot.build.error.merge.form 5 | runbot.build.error.merge 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | runbot.build.error.merge.list 39 | runbot.build.error.merge 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Error merge 50 | error_merge 51 | runbot.build.error.merge 52 | list,form 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /runbot/views/codeowner_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot.codeowner.form 5 | runbot.codeowner 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | runbot.codeowner.list 25 | runbot.codeowner 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Codeowner 40 | runbot.codeowner 41 | list,form 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /runbot/views/custom_trigger_wizard_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot_trigger_custom_wizard 5 | runbot.trigger.custom.wizard 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | Generate custom trigger 42 | runbot.trigger.custom.wizard 43 | form 44 | 45 | new 46 | 47 | form 48 | {'default_bundle_id': active_id} 49 | 50 | 51 | -------------------------------------------------------------------------------- /runbot/views/host_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runbot.host.form 6 | runbot.host 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | runbot.host.list 49 | runbot.host 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | Host 64 | runbot.host 65 | list,form 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /runbot/views/stat_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot.build.stat.regex.form 5 | runbot.build.stat.regex 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | runbot.build.stat.regex.list 24 | runbot.build.stat.regex 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Stat regex 38 | runbot.build.stat.regex 39 | list,form 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /runbot/views/user.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | res.users.form.view 5 | res.users 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | res.users.form.view 16 | res.users 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | res.users.view.search.inherit.runbot 30 | res.users 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Runbot Users Form 43 | res.users 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | res.users.list.inherit 59 | res.users 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /runbot/views/warning_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | runbot.warning.list 5 | runbot.warning 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Warnings 17 | runbot.warning 18 | list 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /runbot/wizards/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import stat_regex_wizard 4 | -------------------------------------------------------------------------------- /runbot/wizards/stat_regex_wizard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | from odoo import fields, models, api 5 | from odoo.exceptions import ValidationError 6 | from odoo.addons.runbot.models.build_stat_regex import VALUE_PATTERN 7 | 8 | 9 | class StatRegexWizard(models.TransientModel): 10 | _name = 'runbot.build.stat.regex.wizard' 11 | _description = "Stat Regex Wizard" 12 | 13 | name = fields.Char("Key Name") 14 | regex = fields.Char("Regular Expression") 15 | description = fields.Char("Description") 16 | generic = fields.Boolean('Generic', help='Executed when no regex on the step', default=True) 17 | test_text = fields.Text("Test text") 18 | key = fields.Char("Key") 19 | value = fields.Float("Value") 20 | message = fields.Char("Wizard message") 21 | 22 | def _validate_regex(self): 23 | try: 24 | regex = re.compile(self.regex) 25 | except re.error as e: 26 | raise ValidationError("Unable to compile regular expression: %s" % e) 27 | if not re.search(VALUE_PATTERN, regex.pattern): 28 | raise ValidationError( 29 | "The regular expresion should contain the name group pattern 'value' e.g: '(?P.+)'" 30 | ) 31 | 32 | @api.onchange('regex', 'test_text') 33 | def _onchange_regex(self): 34 | key = '' 35 | value = False 36 | self.message = '' 37 | if self.regex and self.test_text: 38 | self._validate_regex() 39 | match = re.search(self.regex, self.test_text) 40 | if match: 41 | group_dict = match.groupdict() 42 | try: 43 | value = float(group_dict.get("value")) 44 | except ValueError: 45 | raise ValidationError('The matched value (%s) of "%s" cannot be converted into float' % (group_dict.get("value"), self.regex)) 46 | key = ( 47 | "%s.%s" % (self.name, group_dict["key"]) 48 | if "key" in group_dict 49 | else self.name 50 | ) 51 | else: 52 | self.message = 'No match !' 53 | self.key = key 54 | self.value = value 55 | 56 | def action_save(self): 57 | if self.regex and self.test_text: 58 | self._validate_regex() 59 | stat_regex = self.env['runbot.build.stat.regex'].create({ 60 | 'name': self.name, 61 | 'regex': self.regex, 62 | 'description': self.description, 63 | 'generic': self.generic, 64 | }) 65 | return { 66 | 'name': 'Stat regex', 67 | 'type': 'ir.actions.act_window', 68 | 'res_model': 'runbot.build.stat.regex', 69 | 'view_mode': 'form', 70 | 'res_id': stat_regex.id 71 | } 72 | -------------------------------------------------------------------------------- /runbot/wizards/stat_regex_wizard_views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runbot_stat_regex_wizard 6 | runbot.build.stat.regex.wizard 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | Generate Stat Regex 31 | ir.actions.act_window 32 | runbot.build.stat.regex.wizard 33 | form 34 | 35 | new 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /runbot_builder/builder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import logging 3 | import threading 4 | 5 | from pathlib import Path 6 | 7 | from tools import RunbotClient, run, docker_monitoring_loop 8 | 9 | _logger = logging.getLogger(__name__) 10 | 11 | 12 | class BuilderClient(RunbotClient): 13 | 14 | def on_start(self): 15 | builds_path = self.env['runbot.runbot']._path('build') 16 | monitoring_thread = threading.Thread(target=docker_monitoring_loop, args=(builds_path,), daemon=True) 17 | monitoring_thread.start() 18 | 19 | if self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_do_fetch'): 20 | for repo in self.env['runbot.repo'].search([('mode', '!=', 'disabled')]): 21 | repo._update(force=True) 22 | 23 | self.last_docker_updates = None 24 | 25 | def loop_turn(self): 26 | icp = self.env['ir.config_parameter'] 27 | docker_registry_host_id = icp.get_param('runbot.docker_registry_host_id', default=False) 28 | is_registry = docker_registry_host_id == str(self.host.id) 29 | if is_registry: 30 | self.env['runbot.runbot']._start_docker_registry() 31 | last_docker_updates = self.env['runbot.dockerfile'].search([('to_build', '=', True)]).mapped('write_date') 32 | if self.count == 1 or self.last_docker_updates != last_docker_updates: 33 | self.last_docker_updates = last_docker_updates 34 | self.host._docker_update_images() 35 | self.env.cr.commit() 36 | if self.count == 1: # cleanup at second iteration 37 | self.env['runbot.runbot']._source_cleanup() 38 | self.env.cr.commit() 39 | self.env['runbot.build']._local_cleanup() 40 | self.env.cr.commit() 41 | self.env['runbot.runbot']._docker_cleanup() 42 | self.env.cr.commit() 43 | self.host._set_psql_conn_count() 44 | self.env.cr.commit() 45 | self.env['runbot.repo']._update_git_config() 46 | self.env.cr.commit() 47 | self.git_gc() 48 | self.env.cr.commit() 49 | return self.env['runbot.runbot']._scheduler_loop_turn(self.host) 50 | 51 | 52 | if __name__ == '__main__': 53 | run(BuilderClient) 54 | -------------------------------------------------------------------------------- /runbot_builder/leader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from tools import RunbotClient, run 3 | import logging 4 | import time 5 | 6 | _logger = logging.getLogger(__name__) 7 | 8 | class LeaderClient(RunbotClient): # Conductor, Director, Main, Maestro, Lead 9 | def __init__(self, env): 10 | self.pull_info_failures = {} 11 | super().__init__(env) 12 | 13 | def on_start(self): 14 | if self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_do_fetch'): 15 | _logger.info('Updating all repos') 16 | for repo in self.env['runbot.repo'].search([('mode', '!=', 'disabled')]): 17 | repo._update(force=True) 18 | _logger.info('update finished') 19 | 20 | def loop_turn(self): 21 | if self.count == 0: 22 | self.env['runbot.repo']._update_git_config() 23 | self.env.cr.commit() 24 | self.git_gc() 25 | self.env.cr.commit() 26 | return self.env['runbot.runbot']._fetch_loop_turn(self.host, self.pull_info_failures) 27 | 28 | 29 | if __name__ == '__main__': 30 | run(LeaderClient) 31 | -------------------------------------------------------------------------------- /runbot_builder/tester.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from tools import RunbotClient, run 3 | import logging 4 | 5 | _logger = logging.getLogger(__name__) 6 | 7 | class TesterClient(RunbotClient): 8 | 9 | def loop_turn(self): 10 | _logger.info('='*50) 11 | _logger.info('Testing: %s', self.env['runbot.build'].search_count([('local_state', '=', 'testing')])) 12 | _logger.info('Pending: %s', self.env['runbot.build'].search_count([('local_state', '=', 'pending')])) 13 | return 10 14 | 15 | if __name__ == '__main__': 16 | run(TesterClient) 17 | -------------------------------------------------------------------------------- /runbot_cla/__init__.py: -------------------------------------------------------------------------------- 1 | from . import build_config 2 | -------------------------------------------------------------------------------- /runbot_cla/__manifest__.py: -------------------------------------------------------------------------------- 1 | { 2 | 'name': 'Runbot CLA', 3 | 'category': 'Website', 4 | 'summary': 'Runbot CLA', 5 | 'version': '2.1', 6 | 'description': "Runbot CLA", 7 | 'author': 'Odoo SA', 8 | 'depends': ['runbot'], 9 | 'data': [ 10 | 'data/runbot_build_config_data.xml', 11 | ], 12 | 'license': 'LGPL-3', 13 | } 14 | -------------------------------------------------------------------------------- /runbot_cla/build_config.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import glob 4 | import io 5 | import logging 6 | import re 7 | 8 | from odoo import models, fields 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | 13 | class Step(models.Model): 14 | _inherit = "runbot.build.config.step" 15 | 16 | job_type = fields.Selection(selection_add=[('cla_check', 'Check cla')], ondelete={'cla_check': 'cascade'}) 17 | 18 | def _run_cla_check(self, build): 19 | build._checkout() 20 | cla_glob = glob.glob(build._get_server_commit()._source_path("doc/cla/*/*.md")) 21 | error = False 22 | checked = set() 23 | if cla_glob: 24 | for commit in build.params_id.commit_ids: 25 | email = commit.author_email 26 | if email in checked: 27 | continue 28 | checked.add(email) 29 | build._log('check_cla', "[Odoo CLA signature](https://www.odoo.com/sign-cla) check for %s (%s) ", commit.author, email, log_type='markdown') 30 | mo = re.search('[^ <@]+@[^ @>]+', email or '') 31 | if mo: 32 | email = mo.group(0).lower() 33 | if not re.match(r'.*@(odoo|openerp|tinyerp)\.com$', email): 34 | try: 35 | cla = ''.join(io.open(f, encoding='utf-8').read() for f in cla_glob) 36 | if cla.lower().find(email) == -1: 37 | error = True 38 | build._log('check_cla', 'Email not found in cla file %s' % email, level="ERROR") 39 | except UnicodeDecodeError: 40 | error = True 41 | build._log('check_cla', 'Invalid CLA encoding (must be utf-8)', level="ERROR") 42 | else: 43 | error = True 44 | build._log('check_cla', 'Invalid email format %s' % email, level="ERROR") 45 | else: 46 | error = True 47 | build._log('check_cla', "Missing cla file", level="ERROR") 48 | 49 | if error: 50 | build.local_result = 'ko' 51 | elif not build.local_result: 52 | build.local_result = 'ok' 53 | -------------------------------------------------------------------------------- /runbot_cla/data/runbot_build_config_data.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | cla_check 5 | cla_check 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /runbot_merge/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models, controllers 2 | from .sentry import enable_sentry 3 | 4 | def _check_citext(env): 5 | env.cr.execute("select 1 from pg_extension where extname = 'citext'") 6 | if not env.cr.rowcount: 7 | try: 8 | env.cr.execute('create extension citext') 9 | except Exception: 10 | raise AssertionError("runbot_merge needs the citext extension") 11 | -------------------------------------------------------------------------------- /runbot_merge/__manifest__.py: -------------------------------------------------------------------------------- 1 | { 2 | 'name': 'merge bot', 3 | 'version': '1.15', 4 | 'depends': ['contacts', 'mail', 'website'], 5 | 'data': [ 6 | 'security/security.xml', 7 | 'security/ir.model.access.csv', 8 | 9 | 'data/merge_cron.xml', 10 | 'models/crons/git_maintenance.xml', 11 | 'models/crons/cleanup_scratch_branches.xml', 12 | 'models/crons/issues_closer.xml', 13 | 'data/runbot_merge.pull_requests.feedback.template.csv', 14 | 'views/res_partner.xml', 15 | 'views/runbot_merge_project.xml', 16 | 'views/batch.xml', 17 | 'views/mergebot.xml', 18 | 'views/queues.xml', 19 | 'views/configuration.xml', 20 | 'views/templates.xml', 21 | 'models/project_freeze/views.xml', 22 | 'models/staging_cancel/views.xml', 23 | 'models/backport/views.xml', 24 | ], 25 | 'assets': { 26 | 'web._assets_primary_variables': [ 27 | ('prepend', 'runbot_merge/static/scss/primary_variables.scss'), 28 | ], 29 | 'web.assets_frontend': [ 30 | 'runbot_merge/static/scss/runbot_merge.scss', 31 | ], 32 | 'web.assets_backend': [ 33 | 'runbot_merge/static/scss/runbot_merge_backend.scss', 34 | ], 35 | }, 36 | 'post_load': 'enable_sentry', 37 | 'pre_init_hook': '_check_citext', 38 | 'license': 'LGPL-3', 39 | } 40 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/conflict_authorship.md: -------------------------------------------------------------------------------- 1 | ADD: refuse merging commits without an email set, this is mostly to be used by the forwardport-bot 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/different_project_link.md: -------------------------------------------------------------------------------- 1 | FIX: two PRs with the same label in different projects should not be considered linked anymore 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/drafts.md: -------------------------------------------------------------------------------- 1 | ADD: mergebot should not accept merging draft PR anymore 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/fetch_closed.md: -------------------------------------------------------------------------------- 1 | FIX: when fetching an unknown PR and it's closed, don't lose that information 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/persistent_linked_prs.md: -------------------------------------------------------------------------------- 1 | IMP: keep showing linked PRs after a PR has been merged 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/rebase_tagging.md: -------------------------------------------------------------------------------- 1 | ADD: when integrating a PR via rebasing, tag all the commits with the source PR so they're easier to find 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/staging_failure_message.md: -------------------------------------------------------------------------------- 1 | FIX: when a PR fails at staging, link the correct status in the message posted on the PR 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-09/timestamps.md: -------------------------------------------------------------------------------- 1 | IMP: cleanup timestamp displays, always show the tzoffset, UTC on hover in the main page (easier to relate to logs), local in the per-branch listing 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/changelog.md: -------------------------------------------------------------------------------- 1 | ADD: a changelog feature you can now see here 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/commit-title-edition.md: -------------------------------------------------------------------------------- 1 | FIX: don't rewrite commit titles, this can lead to odd effects when it's incorrectly formatted and interpreted as a pseudo-header 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/pr_description_up_to_date.md: -------------------------------------------------------------------------------- 1 | FIX: ensure the merge message matches the up-to-date PR descriptions, the two could desync if we'd missed an update 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/pr_errors.md: -------------------------------------------------------------------------------- 1 | FIX: correctly display the error message when a PR is in error 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/pr_page.md: -------------------------------------------------------------------------------- 1 | IMP: add reviewer and direct link to backend in PR pages 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/review-without-email.md: -------------------------------------------------------------------------------- 1 | CHG: reject reviewers without an email configured, the fallback to `@users.noreply.github.com` turns out to be confusing 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/reviewer-merge-methods.md: -------------------------------------------------------------------------------- 1 | IMP: allow delegate reviewers to set merge methods 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2021-10/squash.md: -------------------------------------------------------------------------------- 1 | ADD: squash-mode, currently only for single-commit PRs to make it easier to edit commit messages when they're incorrectly formatted 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/alerts.md: -------------------------------------------------------------------------------- 1 | IMP: show current alerts (disabled crons) on the PR pages 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/branch.md: -------------------------------------------------------------------------------- 1 | IMP: automatically close PRs when their target branch is deactivated 2 | 3 | Leave a message on the PRs to explain, such PRs should also be reopen-able if 4 | the users wants to retarget them. 5 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/empty-body.md: -------------------------------------------------------------------------------- 1 | FIX: correctly handle PR empty PR descriptions 2 | 3 | Github's webhook for this case are weird, and weren't handled correctly, 4 | updating a PR's description to *or from* empty might be mishandled. 5 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/pinging.md: -------------------------------------------------------------------------------- 1 | IMP: review pinging (`@`-notification) of users by the mergebot and forwardbot 2 | 3 | The bots should more consistently ping users when they need some sort of action to proceed. 4 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/provisioning.md: -------------------------------------------------------------------------------- 1 | ADD: automated provisioning of accounts from odoo.com 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/ui.md: -------------------------------------------------------------------------------- 1 | IMP: various UI items 2 | 3 | - more clearly differentiate between "pending" and "unknown" statuses on stagings 4 | - fix "outstanding forward ports" count 5 | - add date of staging last modification (= success / failure instant) 6 | - correctly retrieve and include fast-forward and unstaging reasons 7 | - show the warnings banner (e.g. staging disabled) on the PR pages, as not all 8 | users routinely visit the main dashboard 9 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-06/unstaging.md: -------------------------------------------------------------------------------- 1 | FIX: properly unstage pull requests when they're retargeted (base branch is changed) 2 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-10/branch.md: -------------------------------------------------------------------------------- 1 | REV: don't automatically close PRs when their branch is disabled 2 | 3 | Turns out that breaks FW chains and makes existing forward ports harder to 4 | manage, so revert that bit. Do keep sending a message on the PR tho. 5 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-10/labels.md: -------------------------------------------------------------------------------- 1 | IMP: make timestamps and batch labels selectable 2 | 3 | In the list of stagings for a branch, the timestamps and batch labels were not 4 | selectable, which was inconvenent for cross-referencing and copy/pasting. They 5 | should now be selectable. 6 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-10/squash.md: -------------------------------------------------------------------------------- 1 | ADD: enable "squash" merge mode for multi-commit PRs 2 | 3 | After 4 years, the world is apparently ready. Squashing tries to preserve 4 | authorship, depending on the number of authors (and committers) on the PR. 5 | Squashing does *not* preserve any part of existing commit messages. 6 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2022-10/statuses.md: -------------------------------------------------------------------------------- 1 | FIX: lock in statuses at the end of a staging 2 | 3 | The statuses of a staging are computed dynamically. Because github associates 4 | statuses with *commits*, rebuilding a staging (partially or completely) or using 5 | one of its commits for a branch could lead to the statuses becoming inconsistent 6 | with the staging e.g. all-green statuses while the staging had failed. 7 | 8 | By locking in the status at the end of the staging, the dashboard is less 9 | confusing and more consistent, and post-mortem analysis (e.g. of staging 10 | failures) easier. 11 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-08/opts.md: -------------------------------------------------------------------------------- 1 | IMP: optimize home page 2 | 3 | An unnecessary deopt and a few opportunities were found and fixed in the home 4 | page / main dashboard, a few improvements have been implemented which should 5 | significantly lower the number of SQL queries and the time needed to generate 6 | the page. 7 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-08/staging-reverse-index.md: -------------------------------------------------------------------------------- 1 | ADD: stagings reverse index (from commits) 2 | 3 | Finding out the commits from a staging is not great but it's easy enough, the 4 | reverse was difficult and very inefficient. Splat out the "heads" JSON field 5 | into two join tables, and provide both ORM methods and a JSON endpoint to 6 | lookup stagings based on their commits. 7 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-08/stagings-to-prs.md: -------------------------------------------------------------------------------- 1 | IMP: added quick jump from staging to PR in the backend 2 | 3 | In the backend, going through the batches to reach a PR is really not 4 | convenient, directly displaying both github URL and frontend URL for each PR 5 | makes jumping around much easier. 6 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-10/free-the-limit.md: -------------------------------------------------------------------------------- 1 | IMP: allow setting forward-port limits after the source pull request has been merged 2 | 3 | Should now be possible to both extend and retract the forward port limit 4 | afterwards, though obviously no shorter than the current tip of the forward 5 | port sequence. One limitation is that forward ports being created can't be 6 | stopped so there might be some windows where trying to set the limit to the 7 | current tip will fail (because it's in the process of being forward-ported to 8 | the next branch). 9 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-12/commands.md: -------------------------------------------------------------------------------- 1 | CHG: complete rework of the commands system 2 | 3 | # fun is dead: strict commands parsing 4 | 5 | Historically the bots would apply whatever looked like a command and ignore the 6 | rest. This led to people sending novels to the bot, then being surprised the bot 7 | found a command in the mess. 8 | 9 | The bots now ignore all lines which contain any non-command. Example: 10 | 11 | > @robodoo r+ when green darling 12 | 13 | Previously, the bot would apply the `r+` and ignore the rest. Now the bot will 14 | ignore everything and reply with 15 | 16 | > unknown command "when" 17 | 18 | # fwbot is dead 19 | 20 | The mergebot (@robodoo) is now responsible for the old fwbot commands: 21 | 22 | - close, ignore, up to, ... work as they ever did, just with robodoo 23 | - `robodoo r+` now approves the parents if the current PR a forward port 24 | - a specific PR can be approved even in forward ports by providing its number 25 | e.g. `robodoo r=45328` will approve just PR 45328, if that is the PR the 26 | comment is being posted on or one of its parents 27 | - the approval of forward ports won't skip over un-approvable PRs anymore 28 | - the rights of the original author have been restricted slightly: they can 29 | only approve the direct descendents of merged PRs, so if one of the parents 30 | has been modified and is not merged yet, the original author can't approve, 31 | nor can they approve the modified PR, or a conflicting PR which has to get 32 | fixed (?) 33 | 34 | # no more p= 35 | 36 | The old priorities command was a tangle of multiple concerns, not all of which 37 | were always desired or applicable. These tangles have been split along their 38 | various axis. 39 | 40 | # listing 41 | 42 | The new commands are: 43 | 44 | - `default`, sets the staging priority back to the default 45 | - `priority`, sets the staging priority to elevated, on staging these PRs are 46 | staged first, then the `normal` PRs are added 47 | - `alone`, sets the staging priority to high, these PRs are staged before 48 | considering splits, and only `alone` PRs are staged together even if the batch 49 | is not full 50 | - `fw=default`, processes forward ports normally 51 | - `fw=skipci`, once the current PR has been merged creates all the forward ports 52 | without waiting for each to have valid statuses 53 | - `fw=skipmerge`, immediately create all forward ports even if the base pull 54 | request has not even been merged yet 55 | - `skipchecks`, makes the entire batch (target PR and any linked PR) immediately 56 | ready, bypassing statuses and reviews 57 | - `cancel`, cancels the staging on the target branch, if any 58 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-12/staging-priority.md: -------------------------------------------------------------------------------- 1 | ADD: projects now know how to prioritise new PRs over splits 2 | 3 | While this likely has relatively low utility, we'll look at how it performs 4 | during periods of high throughput. 5 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2023-12/staging-shutdown.md: -------------------------------------------------------------------------------- 1 | ADD: stagings can now be disabled on a per-project basis 2 | 3 | Currently stopping stagings requires stopping the staging cron(s), which causes 4 | several issues: 5 | 6 | - the staging cron runs very often, so it can be difficult to find a window to 7 | deactivate it (as the cron runner acquires an exclusive lock on the cron) 8 | - the staging cron is global, so it does not disable staging only on the 9 | problematic project (to say nothing of branch) but on all of them 10 | 11 | The latter is not currently a huge issue as only one of the mergebot-tracked 12 | projects is ultra active (spreadsheet activity is on the order of a few 13 | single-PR stagings a day), but the former is really annoying when trying to 14 | stop runaway broken stagings. 15 | -------------------------------------------------------------------------------- /runbot_merge/changelog/2024-08/description.md: -------------------------------------------------------------------------------- 1 | IMP: PR descriptions are now markdown-rendered in the dashboard 2 | 3 | Previously the raw text was displayed. The main advantage of rendering, aside 4 | from not splatting huge links in the middle of the thing, is that we can 5 | autolink *odoo tasks* if they're of a pattern we recognize. Some support has 6 | also been added for github's references to mirror GFM rendering. 7 | 8 | This would be a lot less useful (and in fact pretty much useless) if we could 9 | use github's built-in [references to external resources](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/configuring-autolinks-to-reference-external-resources) 10 | sadly that seems to not be available on our plan. 11 | -------------------------------------------------------------------------------- /runbot_merge/data/merge_cron.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Check for progress of (and merge) stagings 4 | 5 | code 6 | model._check_stagings(True) 7 | 6 8 | hours 9 | -1 10 | 11 | 30 12 | 13 | 14 | Check for progress of PRs and create Stagings 15 | 16 | code 17 | model._create_stagings(True) 18 | 6 19 | hours 20 | -1 21 | 22 | 40 23 | 24 | 25 | Send feedback to PR 26 | 27 | code 28 | model._send() 29 | 6 30 | hours 31 | -1 32 | 33 | 60 34 | 35 | 36 | Update labels on PR 37 | 38 | code 39 | model._send() 40 | 10 41 | hours 42 | -1 43 | 44 | 70 45 | 46 | 47 | Check for PRs to fetch 48 | 49 | code 50 | model._check(True) 51 | 6 52 | hours 53 | -1 54 | 55 | 10 56 | 57 | 58 | Warn on linked PRs where only one is ready 59 | 60 | code 61 | model._check_linked_prs_statuses(True) 62 | 1 63 | hours 64 | -1 65 | 66 | 50 67 | 68 | 69 | Impact commit statuses on PRs and stagings 70 | 71 | code 72 | model._notify() 73 | 6 74 | hours 75 | -1 76 | 77 | 20 78 | 79 | 80 | -------------------------------------------------------------------------------- /runbot_merge/exceptions.py: -------------------------------------------------------------------------------- 1 | class MergeError(Exception): 2 | pass 3 | class FastForwardError(Exception): 4 | pass 5 | class Mismatch(MergeError): 6 | pass 7 | class Unmergeable(MergeError): 8 | pass 9 | 10 | class Skip(MergeError): 11 | pass 12 | -------------------------------------------------------------------------------- /runbot_merge/localtunnel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | import signal 4 | import subprocess 5 | import sys 6 | import threading 7 | 8 | port = int(sys.argv[1]) 9 | p = subprocess.Popen(['lt', '-p', str(port)], stdout=subprocess.PIPE, encoding="utf-8") 10 | r = p.stdout.readline() 11 | m = re.match(r'your url is: (https://.*\.localtunnel\.me)', r) 12 | assert m, "could not get the localtunnel URL" 13 | print(m[1], flush=True) 14 | sys.stdout.close() 15 | 16 | shutdown = threading.Event() 17 | def cleanup(_sig, _frame): 18 | p.terminate() 19 | p.wait(30) 20 | shutdown.set() 21 | 22 | signal.signal(signal.SIGTERM, cleanup) 23 | signal.signal(signal.SIGINT, cleanup) 24 | 25 | shutdown.wait() 26 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.1/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ Moved the required_statuses field from the project to the repository so 3 | different repos can have different CI requirements within a project 4 | """ 5 | # create column on repo 6 | cr.execute("ALTER TABLE runbot_merge_repository ADD COLUMN required_statuses varchar") 7 | # copy data from project 8 | cr.execute(""" 9 | UPDATE runbot_merge_repository r 10 | SET required_statuses = ( 11 | SELECT required_statuses 12 | FROM runbot_merge_project 13 | WHERE id = r.project_id 14 | ) 15 | """) 16 | # drop old column on project 17 | cr.execute("ALTER TABLE runbot_merge_project DROP COLUMN required_statuses") 18 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.2/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute(""" 3 | create table res_partner_review ( 4 | id serial primary key, 5 | partner_id integer not null references res_partner (id), 6 | repository_id integer not null references runbot_merge_repository (id), 7 | review bool, 8 | self_review bool 9 | ) 10 | """) 11 | cr.execute(""" 12 | insert into res_partner_review (partner_id, repository_id, review, self_review) 13 | select p.id, r.id, reviewer, self_reviewer 14 | from res_partner p, runbot_merge_repository r 15 | where p.reviewer or p.self_reviewer 16 | """) 17 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.3/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("DROP INDEX runbot_merge_unique_gh_login") 3 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.4/pre-migration.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def migrate(cr, version): 4 | """ required_statuses is now a separate object in its own table 5 | """ 6 | # apparently the DDL has already been updated but the reflection gunk 7 | cr.execute(""" 8 | DELETE FROM ir_model_fields 9 | WHERE model = 'runbot_merge.pull_requests.tagging' 10 | AND name in ('state_from', 'state_to') 11 | """) 12 | 13 | cr.execute(""" 14 | CREATE TABLE runbot_merge_repository_status ( 15 | id SERIAL NOT NULL PRIMARY KEY, 16 | context VARCHAR NOT NULL, 17 | repo_id INTEGER NOT NULL REFERENCES runbot_merge_repository (id) ON DELETE CASCADE, 18 | prs BOOLEAN, 19 | stagings BOOLEAN 20 | ) 21 | """) 22 | cr.execute(""" 23 | CREATE TABLE runbot_merge_repository_status_branch ( 24 | status_id INTEGER NOT NULL REFERENCES runbot_merge_repository_status (id) ON DELETE CASCADE, 25 | branch_id INTEGER NOT NULL REFERENCES runbot_merge_branch (id) ON DELETE CASCADE 26 | ) 27 | """) 28 | 29 | cr.execute('select id, required_statuses from runbot_merge_repository') 30 | for repo, statuses in cr.fetchall(): 31 | for st in re.split(r',\s*', statuses): 32 | cr.execute(""" 33 | INSERT INTO runbot_merge_repository_status (context, repo_id, prs, stagings) 34 | VALUES (%s, %s, true, true) 35 | """, [st, repo]) 36 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.5/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ copy required status filters from an m2m to branches to a domain 3 | """ 4 | cr.execute(""" 5 | ALTER TABLE runbot_merge_repository_status 6 | ADD COLUMN branch_filter varchar 7 | """) 8 | cr.execute(''' 9 | SELECT status_id, array_agg(branch_id) 10 | FROM runbot_merge_repository_status_branch 11 | GROUP BY status_id 12 | ''') 13 | for st, brs in cr.fetchall(): 14 | cr.execute(""" 15 | UPDATE runbot_merge_repository_status 16 | SET branch_filter = %s 17 | WHERE id = %s 18 | """, [ 19 | repr([('id', 'in', brs)]), 20 | st 21 | ]) 22 | cr.execute("DROP TABLE runbot_merge_repository_status_branch") 23 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.6/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ Status overrides: o2m -> m2m 3 | """ 4 | # create link table 5 | cr.execute(''' 6 | CREATE TABLE res_partner_res_partner_override_rel ( 7 | res_partner_id integer not null references res_partner (id) ON DELETE CASCADE, 8 | res_partner_override_id integer not null references res_partner_override (id) ON DELETE CASCADE, 9 | primary key (res_partner_id, res_partner_override_id) 10 | ) 11 | ''') 12 | cr.execute(''' 13 | CREATE UNIQUE INDEX ON res_partner_res_partner_override_rel 14 | (res_partner_override_id, res_partner_id) 15 | ''') 16 | 17 | # deduplicate override rights and insert into link table 18 | cr.execute('SELECT array_agg(id), array_agg(partner_id)' 19 | ' FROM res_partner_override GROUP BY repository_id, context') 20 | links = {} 21 | duplicants = set() 22 | for [keep, *drops], partners in cr.fetchall(): 23 | links[keep] = partners 24 | duplicants.update(drops) 25 | for override_id, partner_ids in links.items(): 26 | for partner_id in partner_ids: 27 | cr.execute('INSERT INTO res_partner_res_partner_override_rel (res_partner_override_id, res_partner_id)' 28 | ' VALUES (%s, %s)', [override_id, partner_id]) 29 | # drop dups 30 | cr.execute('DELETE FROM res_partner_override WHERE id = any(%s)', [list(duplicants)]) 31 | 32 | # remove old partner field 33 | cr.execute('ALTER TABLE res_partner_override DROP COLUMN partner_id') 34 | # add constraint to overrides 35 | cr.execute('CREATE UNIQUE INDEX res_partner_override_unique ON res_partner_override ' 36 | '(context, coalesce(repository_id, 0))') 37 | -------------------------------------------------------------------------------- /runbot_merge/migrations/13.0.1.7/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | """ Create draft column manually because the v13 orm can't handle the power 3 | of adding new required columns 4 | """ 5 | cr.execute("ALTER TABLE runbot_merge_pull_requests" 6 | " ADD COLUMN draft BOOLEAN NOT NULL DEFAULT false") 7 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.10/pre-migration.py: -------------------------------------------------------------------------------- 1 | """ Migration for the unified commands parser, fp_github fields moved from 2 | forwardport to mergebot (one of them is removed but we might not care) 3 | """ 4 | def migrate(cr, version): 5 | cr.execute(""" 6 | UPDATE ir_model_data 7 | SET module = 'runbot_merge' 8 | WHERE module = 'forwardport' 9 | AND model = 'ir.model.fields' 10 | AND name in ('fp_github_token', 'fp_github_name') 11 | """) 12 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.13/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("ALTER TABLE runbot_merge_stagings " 3 | "ADD COLUMN staging_end timestamp without time zone") 4 | cr.execute("UPDATE runbot_merge_stagings SET staging_end = write_date") 5 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.14/pre-migration.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute(""" 3 | CREATE TABLE runbot_merge_events_sources ( 4 | id serial primary key, 5 | repository varchar not null, 6 | secret varchar 7 | ); 8 | INSERT INTO runbot_merge_events_sources (repository, secret) 9 | SELECT r.name, p.secret 10 | FROM runbot_merge_repository r 11 | JOIN runbot_merge_project p ON p.id = r.project_id; 12 | """) 13 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.15/pre-migration.py: -------------------------------------------------------------------------------- 1 | """Completely missed that in 44084e303ccece3cb54128ab29eab399bd4d24e9 I 2 | completely changed the semantics and structure of the statuses_cache, so the 3 | old caches don't actually work anymore at all. 4 | 5 | This rewrites all existing caches. 6 | """ 7 | def migrate(cr, version): 8 | cr.execute(""" 9 | WITH statuses AS ( 10 | SELECT 11 | s.id as staging_id, 12 | json_object_agg(c.sha, c.statuses::json) as statuses 13 | FROM runbot_merge_stagings s 14 | LEFT JOIN runbot_merge_stagings_heads h ON (h.staging_id = s.id) 15 | LEFT JOIN runbot_merge_commit c ON (h.commit_id = c.id) 16 | GROUP BY s.id 17 | ) 18 | UPDATE runbot_merge_stagings 19 | SET statuses_cache = statuses 20 | FROM statuses 21 | WHERE id = staging_id 22 | """) 23 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.8/pre-migration.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | def migrate(cr, version): 4 | sql = Path(__file__).parent.joinpath('upgrade.sql')\ 5 | .read_text(encoding='utf-8') 6 | cr.execute(sql) 7 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.8/upgrade.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE runbot_merge_stagings_commits ( 2 | id serial NOT NULL, 3 | staging_id integer not null references runbot_merge_stagings (id), 4 | commit_id integer not null references runbot_merge_commit (id), 5 | repository_id integer not null references runbot_merge_repository (id) 6 | ); 7 | 8 | CREATE TABLE runbot_merge_stagings_heads ( 9 | id serial NOT NULL, 10 | staging_id integer NOT NULL REFERENCES runbot_merge_stagings (id), 11 | commit_id integer NOT NULL REFERENCES runbot_merge_commit (id), 12 | repository_id integer NOT NULL REFERENCES runbot_merge_repository (id) 13 | ); 14 | 15 | -- some of the older stagings only have the head, not the commit, 16 | -- add the commit 17 | UPDATE runbot_merge_stagings 18 | SET heads = heads::jsonb || jsonb_build_object( 19 | 'odoo/odoo^', heads::json->'odoo/odoo', 20 | 'odoo/enterprise^', heads::json->'odoo/enterprise' 21 | ) 22 | WHERE heads NOT ILIKE '%^%'; 23 | 24 | -- some of the stagings have heads which don't exist in the commits table, 25 | -- because they never got a status from the runbot... 26 | -- create fake commits so we don't lose heads 27 | INSERT INTO runbot_merge_commit (sha, statuses, create_uid, create_date, write_uid, write_date) 28 | SELECT r.value, '{}', s.create_uid, s.create_date, s.create_uid, s.create_date 29 | FROM runbot_merge_stagings s, 30 | json_each_text(s.heads::json) r 31 | ON CONFLICT DO NOTHING; 32 | 33 | CREATE TEMPORARY TABLE staging_commits ( 34 | id integer NOT NULL, 35 | repo integer NOT NULL, 36 | -- the staging head (may be a dedup, may be the same as commit) 37 | head integer NOT NULL, 38 | -- the staged commit 39 | commit integer NOT NULL 40 | ); 41 | -- the splatting works entirely off of the staged head 42 | -- (the one without the ^ suffix), we concat the `^` to get the corresponding 43 | -- merge head (the actual commit to push to the branch) 44 | INSERT INTO staging_commits (id, repo, head, commit) 45 | SELECT s.id, re.id AS repo, h.id AS head, c.id AS commit 46 | FROM runbot_merge_stagings s, 47 | json_each_text(s.heads::json) r, 48 | runbot_merge_commit h, 49 | runbot_merge_commit c, 50 | runbot_merge_repository re 51 | WHERE r.key NOT ILIKE '%^' 52 | AND re.name = r.key 53 | AND h.sha = r.value 54 | AND c.sha = s.heads::json->>(r.key || '^'); 55 | 56 | INSERT INTO runbot_merge_stagings_heads (staging_id, repository_id, commit_id) 57 | SELECT id, repo, head FROM staging_commits; 58 | 59 | INSERT INTO runbot_merge_stagings_commits (staging_id, repository_id, commit_id) 60 | SELECT id, repo, commit FROM staging_commits; 61 | 62 | ALTER TABLE runbot_merge_stagings DROP COLUMN heads; 63 | -------------------------------------------------------------------------------- /runbot_merge/migrations/15.0.1.9/pre-migration.py: -------------------------------------------------------------------------------- 1 | from psycopg2.extras import execute_values 2 | 3 | 4 | def migrate(cr, version): 5 | # Drop all legacy style "previous failures": this is for PRs 6 | # several years old so almost certainly long irrelevant, and it 7 | # allows removing the workaround for them. Legacy style has the 8 | # `state`, `target`, `description` keys at the toplevel while new 9 | # style is like commit statuses, with the contexts at the toplevel 10 | # and the status info below. 11 | cr.execute(""" 12 | UPDATE runbot_merge_pull_requests 13 | SET previous_failure = '{}' 14 | WHERE previous_failure::jsonb ? 'state' 15 | """) 16 | 17 | cr.execute(""" 18 | WITH new_statuses (id, statuses) AS ( 19 | SELECT id, json_object_agg( 20 | key, 21 | CASE WHEN jsonb_typeof(value) = 'string' 22 | THEN jsonb_build_object('state', value, 'target_url', null, 'description', null) 23 | ELSE value 24 | END 25 | ) AS statuses 26 | FROM runbot_merge_commit 27 | CROSS JOIN LATERAL jsonb_each(statuses::jsonb) s 28 | WHERE jsonb_path_match(statuses::jsonb, '$.*.type() != "object"') 29 | GROUP BY id 30 | ) 31 | UPDATE runbot_merge_commit SET statuses = new_statuses.statuses FROM new_statuses WHERE runbot_merge_commit.id = new_statuses.id 32 | """) 33 | -------------------------------------------------------------------------------- /runbot_merge/migrations/17.0.1.15/pre-migration.py: -------------------------------------------------------------------------------- 1 | from odoo.upgrade import util 2 | 3 | def migrate(cr, _version): 4 | util.remove_field(cr, "res.partner", "message_main_attachment_id") 5 | util.remove_field(cr, "runbot_merge.batch", "message_main_attachment_id") 6 | util.remove_field(cr, "runbot_merge.patch", "message_main_attachment_id") 7 | util.remove_field(cr, "runbot_merge.pull_requests", "message_main_attachment_id") 8 | util.remove_field(cr, "runbot_merge.pull_requests.feedback.template", "message_main_attachment_id") 9 | -------------------------------------------------------------------------------- /runbot_merge/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import mail_thread 2 | from . import ir_actions 3 | from . import ir_ui_view 4 | from . import res_partner 5 | from . import project 6 | from . import pull_requests 7 | from . import batch 8 | from . import patcher 9 | from . import project_freeze 10 | from . import stagings_create 11 | from . import staging_cancel 12 | from . import backport 13 | from . import events_sources 14 | from . import crons 15 | -------------------------------------------------------------------------------- /runbot_merge/models/backport/views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Backport Wizard 4 | runbot_merge.pull_requests.backport 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Perform backport from current PR 17 | ir.actions.server 18 | 19 | 20 | code 21 | action = record.backport() 22 | 23 | 24 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/__init__.py: -------------------------------------------------------------------------------- 1 | from . import git_maintenance 2 | from . import cleanup_scratch_branches 3 | from . import issues_closer 4 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/cleanup_scratch_branches.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from odoo import models 4 | 5 | 6 | _logger = logging.getLogger(__name__) 7 | class BranchCleanup(models.TransientModel): 8 | _name = 'runbot_merge.branch_cleanup' 9 | _description = "cleans up scratch refs for deactivated branches" 10 | 11 | def _run(self): 12 | domain = [('active', '=', False)] 13 | if lastcall := self.env.context['lastcall']: 14 | domain.append(('write_date', '>=', lastcall)) 15 | deactivated = self.env['runbot_merge.branch'].search(domain) 16 | 17 | _logger.info( 18 | "deleting scratch (tmp and staging) refs for branches %s", 19 | ', '.join(b.name for b in deactivated) 20 | ) 21 | # loop around the repos first, so we can reuse the gh instance 22 | for r in deactivated.mapped('project_id.repo_ids'): 23 | gh = r.github() 24 | for b in deactivated: 25 | if b.project_id != r.project_id: 26 | continue 27 | 28 | res = gh('delete', f'git/refs/heads/tmp.{b.name}', check=False) 29 | if res.status_code != 204: 30 | _logger.info("no tmp branch found for %s:%s", r.name, b.name) 31 | res = gh('delete', f'git/refs/heads/staging.{b.name}', check=False) 32 | if res.status_code != 204: 33 | _logger.info("no staging branch found for %s:%s", r.name, b.name) 34 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/cleanup_scratch_branches.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Access to branch cleanup is useless 4 | 5 | 0 6 | 0 7 | 0 8 | 0 9 | 10 | 11 | 12 | Removal of scratch refs for deactivated branch 13 | 14 | code 15 | model._run() 16 | 20 | -1 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/git_maintenance.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | from odoo import models 5 | from ...git import get_local 6 | 7 | 8 | _gc = logging.getLogger(__name__) 9 | class GC(models.TransientModel): 10 | _name = 'runbot_merge.maintenance' 11 | _description = "Weekly maintenance of... cache repos?" 12 | 13 | def _run(self): 14 | # lock out crons which use the local repo cache to avoid concurrency 15 | # issues while we're GC-ing it 16 | Stagings = self.env['runbot_merge.stagings'] 17 | crons = self.env.ref('runbot_merge.staging_cron', Stagings) | self.env.ref('forwardport.port_forward', Stagings) 18 | if crons: 19 | self.env.cr.execute(""" 20 | SELECT 1 FROM ir_cron 21 | WHERE id = any(%s) 22 | FOR UPDATE 23 | """, [crons.ids]) 24 | 25 | # run on all repos with a forwardport target (~ forwardport enabled) 26 | for repo in self.env['runbot_merge.repository'].search([]): 27 | repo_git = get_local(repo, clone=False) 28 | if not repo_git: 29 | continue 30 | 31 | _gc.info('Running maintenance on %s', repo.name) 32 | r = repo_git\ 33 | .stdout(True)\ 34 | .with_config(stderr=subprocess.STDOUT, text=True, check=False)\ 35 | .remote('prune', 'origin') 36 | if r.returncode: 37 | _gc.warning("Prune failure (status=%d):\n%s", r.returncode, r.stdout) 38 | 39 | r = repo_git\ 40 | .stdout(True)\ 41 | .with_config(stderr=subprocess.STDOUT, text=True, check=False)\ 42 | .gc('--prune=now', aggressive=True) 43 | if r.returncode: 44 | _gc.warning("GC failure (status=%d):\n%s", r.returncode, r.stdout) 45 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/git_maintenance.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Access to maintenance is useless 4 | 5 | 0 6 | 0 7 | 0 8 | 0 9 | 10 | 11 | 12 | Maintenance of repo cache 13 | 14 | code 15 | model._run() 16 | 20 | 21 | 1 22 | weeks 23 | -1 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/issues_closer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from odoo import models, fields 4 | 5 | _logger = logging.getLogger(__name__) 6 | class BranchCleanup(models.Model): 7 | _name = 'runbot_merge.issues_closer' 8 | _description = "closes issues linked to PRs" 9 | 10 | repository_id = fields.Many2one('runbot_merge.repository', required=True) 11 | number = fields.Integer(required=True) 12 | 13 | def _run(self): 14 | ghs = {} 15 | while t := self.search([], limit=1): 16 | gh = ghs.get(t.repository_id.id) 17 | if not gh: 18 | gh = ghs[t.repository_id.id] = t.repository_id.github() 19 | 20 | r = gh('PATCH', f'issues/{t.number}', json={'state': 'closed'}, check=False) 21 | t.unlink() 22 | -------------------------------------------------------------------------------- /runbot_merge/models/crons/issues_closer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Access to branch cleanup is useless 4 | 5 | 0 6 | 0 7 | 0 8 | 0 9 | 10 | 11 | 12 | Close issues linked to merged PRs 13 | 14 | code 15 | model._run() 16 | 20 | -1 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /runbot_merge/models/events_sources.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields 2 | 3 | 4 | class EventsSources(models.Model): 5 | _name = 'runbot_merge.events_sources' 6 | _description = 'Valid Webhook Event Sources' 7 | _order = "repository" 8 | _rec_name = "repository" 9 | 10 | # FIXME: unique repo? Or allow multiple secrets per repo? 11 | repository = fields.Char(index=True, required=True) 12 | secret = fields.Char() 13 | -------------------------------------------------------------------------------- /runbot_merge/models/ir_actions.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from odoo import models 5 | 6 | class ExtendedServerActionContext(models.Model): 7 | _inherit = 'ir.actions.server' 8 | 9 | def _get_eval_context(self, action=None): 10 | ctx = super()._get_eval_context(action=action) 11 | ctx.update(requests=requests.Session(), loads=json.loads, dumps=json.dumps) 12 | return ctx 13 | -------------------------------------------------------------------------------- /runbot_merge/models/ir_ui_view.py: -------------------------------------------------------------------------------- 1 | from odoo import models 2 | 3 | 4 | class View(models.Model): 5 | _inherit = 'ir.ui.view' 6 | 7 | def _log_view_warning(self, msg, node): 8 | """The view validator is dumb and triggers a warning because there's a 9 | `field.btn`, even though making a `field[widget=url]` (which renders as 10 | a link) look like a button is perfectly legitimate. 11 | 12 | Suppress that warning. 13 | """ 14 | if node.tag == 'field' and node.get('widget') == 'url' and "button/submit/reset" in msg: 15 | return 16 | 17 | super()._log_view_warning(msg, node) 18 | -------------------------------------------------------------------------------- /runbot_merge/models/mail_thread.py: -------------------------------------------------------------------------------- 1 | from collections import ChainMap 2 | 3 | from odoo import models 4 | from odoo.tools import ConstantMapping 5 | 6 | 7 | class MailThread(models.AbstractModel): 8 | _inherit = 'mail.thread' 9 | 10 | def _message_compute_author(self, author_id=None, email_from=None, raise_on_email=True): 11 | if author_id is None and self: 12 | mta = self.env.cr.precommit.data.get(f'mail.tracking.author.{self._name}', {}) 13 | authors = self.env['res.partner'].union(*(p for r in self if (p := mta.get(r.id)))) 14 | if len(authors) == 1: 15 | author_id = authors.id 16 | v = super()._message_compute_author(author_id, email_from, raise_on_email) 17 | return v 18 | 19 | def _track_set_author(self, author, *, fallback=False): 20 | """ Set the author of the tracking message. """ 21 | if not self._track_get_fields(): 22 | return 23 | authors = self.env.cr.precommit.data.setdefault(f'mail.tracking.author.{self._name}', {}) 24 | if fallback: 25 | details = authors 26 | if isinstance(authors, ChainMap): 27 | details = authors.maps[0] 28 | self.env.cr.precommit.data[f'mail.tracking.author.{self._name}'] = ChainMap( 29 | details, 30 | ConstantMapping(author), 31 | ) 32 | else: 33 | return super()._track_set_author(author) 34 | -------------------------------------------------------------------------------- /runbot_merge/models/staging_cancel/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from odoo import models, fields 4 | 5 | _logger = logging.getLogger(__name__) 6 | class CancelWizard(models.TransientModel): 7 | _name = 'runbot_merge.stagings.cancel' 8 | _description = "Wizard for cancelling a staging" 9 | 10 | staging_id = fields.Many2one('runbot_merge.stagings', required=True) 11 | reason = fields.Char() 12 | cancel_splits = fields.Boolean(help="\ 13 | If any split is pending, also cancel them and move the corresponding \ 14 | pull requests back into the general pool.") 15 | 16 | def action_cancel(self): 17 | if self.cancel_splits: 18 | self.env['runbot_merge.split'].search([ 19 | ('target', '=', self.staging_id.target.id) 20 | ]).unlink() 21 | 22 | reason = self.reason.replace('%', '%%').strip() if self.reason else '' 23 | if reason: 24 | reason = f' because {reason}' 25 | self.staging_id.cancel(f'Cancelled by {self.env.user.display_name}{reason}') 26 | self.unlink() 27 | return { 'type': 'ir.actions.act_window_close' } 28 | -------------------------------------------------------------------------------- /runbot_merge/models/staging_cancel/views.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Staging Cancel Form 4 | runbot_merge.stagings.cancel 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /runbot_merge/ngrok: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import contextlib 3 | import random 4 | import signal 5 | import subprocess 6 | import threading 7 | import time 8 | 9 | import requests 10 | import sys 11 | 12 | port = int(sys.argv[1]) 13 | 14 | NGROK_CLI = [ 15 | 'ngrok', 'start', '--none', '--region', 'eu', 16 | ] 17 | 18 | own = None 19 | web_addr = 'http://localhost:4040/api' 20 | addr = 'localhost:%d' % port 21 | 22 | # FIXME: this is for xdist to avoid workers running ngrok at the 23 | # exact same time, use lockfile instead 24 | time.sleep(random.SystemRandom().randint(1, 10)) 25 | # try to find out if ngrok is running, and if it's not attempt 26 | # to start it 27 | try: 28 | requests.get(web_addr) 29 | except requests.exceptions.ConnectionError: 30 | own = subprocess.Popen(NGROK_CLI, stdout=subprocess.DEVNULL) 31 | for _ in range(5): 32 | time.sleep(1) 33 | with contextlib.suppress(requests.exceptions.ConnectionError): 34 | requests.get(web_addr) 35 | break 36 | else: 37 | sys.exit("Unable to connect to ngrok") 38 | 39 | requests.post(f'{web_addr}/tunnels', json={ 40 | 'name': str(port), 41 | 'proto': 'http', 42 | 'addr': addr, 43 | 'schemes': ['https'], 44 | 'inspect': True, 45 | }).raise_for_status() 46 | 47 | 48 | tunnel = f'{web_addr}/tunnels/{port}' 49 | for _ in range(10): 50 | time.sleep(2) 51 | r = requests.get(tunnel) 52 | # not created yet, wait and retry 53 | if r.status_code == 404: 54 | continue 55 | # check for weird responses 56 | r.raise_for_status() 57 | 58 | print("opened tunnel", file=sys.stderr) 59 | print(r.json()['public_url'], flush=True) 60 | sys.stdout.close() 61 | break 62 | else: 63 | sys.exit("ngrok tunnel creation failed (?)") 64 | 65 | shutdown = threading.Event() 66 | def cleanup(_sig, _frame): 67 | requests.delete(tunnel) 68 | for _ in range(10): 69 | time.sleep(1) 70 | r = requests.get(tunnel) 71 | # check if deletion is done 72 | if r.status_code == 404: 73 | break 74 | r.raise_for_status() 75 | else: 76 | raise sys.exit("ngrok tunnel deletion failed") 77 | 78 | r = requests.get(f'{web_addr}/tunnels') 79 | if not r.ok: 80 | sys.exit(f'{r.reason} {r.text}') 81 | # FIXME: if we started ngrok, we should probably wait for all tunnels to be 82 | # closed then terminate ngrok? This is likely a situation where the 83 | # worker which created the ngrok instance finished early... 84 | if own and not r.json()['tunnels']: 85 | # no more tunnels and we started ngrok -> try to kill it 86 | own.terminate() 87 | own.wait(30) 88 | shutdown.set() 89 | 90 | # don't know why but signal.sigwait doesn't seem to take SIGTERM in account so 91 | # we need the cursed version 92 | signal.signal(signal.SIGTERM, cleanup) 93 | signal.signal(signal.SIGINT, cleanup) 94 | 95 | print("wait for signal", file=sys.stderr) 96 | shutdown.wait() 97 | -------------------------------------------------------------------------------- /runbot_merge/security/security.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mergebot Patcher 4 | 7 | 8 | 9 | Mergebot Administrator 10 | 13 | 14 | 15 | 19 | 20 | 21 | Mergebot Status Sender 22 | 23 | 24 | -------------------------------------------------------------------------------- /runbot_merge/static/scss/primary_variables.scss: -------------------------------------------------------------------------------- 1 | // colors from the original mergebot 2 | $primary: #276e72; 3 | $secondary: #685563; 4 | $success: #28a745; 5 | $info: #17a2b8; 6 | $warning: #ffc107; 7 | $danger: #dc3545; 8 | $theme-colors: ( 9 | "primary": $primary, 10 | "secondary": $secondary, 11 | "success": $success, 12 | "info": $info, 13 | "warning": $warning, 14 | "danger": $danger, 15 | ); 16 | 17 | $o-system-fonts: sans-serif; 18 | 19 | // mostly revets a bunch of shit from the default odoo theming 20 | $link-color: $primary; 21 | $o-community-color: $primary; 22 | $o-enterprise-color: $primary; 23 | 24 | $font-size-root: null; 25 | $font-size-base: 0.875rem; 26 | $font-size-sm: $font-size-base * .875; 27 | $font-size-lg: $font-size-base * 1.25; 28 | 29 | $font-weight-lighter: lighter; 30 | $font-weight-light: 300; 31 | $font-weight-normal: 400; 32 | $font-weight-bold: 500; 33 | $font-weight-bolder: bolder; 34 | 35 | $font-weight-base: $font-weight-normal; 36 | 37 | $line-height-base: 1.5; 38 | $line-height-sm: 1.25; 39 | $line-height-lg: 2; 40 | 41 | $h1-font-size: $font-size-base * 2.5; 42 | $h2-font-size: $font-size-base * 2; 43 | $h3-font-size: $font-size-base * 1.75; 44 | $h4-font-size: $font-size-base * 1.5; 45 | $h5-font-size: $font-size-base; 46 | $h6-font-size: $font-size-base; 47 | 48 | $border-radius: 0.25rem; 49 | -------------------------------------------------------------------------------- /runbot_merge/static/scss/runbot_merge_backend.scss: -------------------------------------------------------------------------------- 1 | @for $item from 2 through length($o-colors) { 2 | .fucking_color_key_#{$item - 1} { 3 | $background-color: nth($o-colors, $item); 4 | color: color-contrast($background-color); 5 | background-color: $background-color; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /runbot_merge/tests/README.rst: -------------------------------------------------------------------------------- 1 | Execute this test suite using pytest. 2 | 3 | The default mode is to run tests locally using a mock github.com. 4 | 5 | See the docstring of remote.py for instructions to run against github "actual" 6 | (including remote-specific options) and the end of this file for a sample. 7 | 8 | Shared properties running tests, regardless of the github implementation: 9 | 10 | * test should be run from the root of the runbot repository providing the 11 | name of this module aka ``pytest runbot_merge`` or 12 | ``python -mpytest runbot_merge`` 13 | * a database name to use must be provided using ``--db``, the database should 14 | not exist beforehand 15 | * the addons path must be specified using ``--addons-path``, both "runbot" and 16 | the standard addons (odoo/addons) must be provided explicitly 17 | 18 | See pytest's documentation for other options, I would recommend ``-rXs``, 19 | ``-v`` and ``--showlocals``. 20 | 21 | When running "remote" tests as they take a very long time (hours) ``-x`` 22 | (aka ``--maxfail=1``) and ``--ff`` (run previously failed first) is also 23 | recommended unless e.g. you run the tests overnight. 24 | 25 | ``pytest.ini`` sample 26 | --------------------- 27 | 28 | .. code:: ini 29 | 30 | [github] 31 | owner = test-org 32 | token = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 33 | 34 | [role_reviewer] 35 | name = Dick Bong 36 | user = loginb 37 | token = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 38 | 39 | [role_self_reviewer] 40 | name = Fanny Chmelar 41 | user = loginc 42 | token = cccccccccccccccccccccccccccccccccccccccc 43 | 44 | [role_other] 45 | name = Harry Baals 46 | user = logind 47 | token = dddddddddddddddddddddddddddddddddddddddd 48 | -------------------------------------------------------------------------------- /runbot_merge/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture() 4 | def module(): 5 | return 'runbot_merge' 6 | 7 | @pytest.fixture 8 | def project(env, config): 9 | return env['runbot_merge.project'].create({ 10 | 'name': 'odoo', 11 | 'github_token': config['github']['token'], 12 | 'github_prefix': 'hansen', 13 | 'github_name': config['github']['name'], 14 | 'github_email': "foo@example.org", 15 | 'branch_ids': [(0, 0, {'name': 'master'})], 16 | }) 17 | 18 | 19 | @pytest.fixture 20 | def make_repo2(env, project, make_repo, users, setreviewers): 21 | """Layer over ``make_repo`` which also: 22 | 23 | - adds the new repo to ``project`` (with no group and the ``'default'`` status required) 24 | - sets the standard reviewers on the repo 25 | - and creates an event source for the repo 26 | """ 27 | def mr(name): 28 | r = make_repo(name) 29 | rr = env['runbot_merge.repository'].create({ 30 | 'project_id': project.id, 31 | 'name': r.name, 32 | 'group_id': False, 33 | 'required_statuses': 'default', 34 | }) 35 | setreviewers(rr) 36 | env['runbot_merge.events_sources'].create({'repository': r.name}) 37 | return r 38 | return mr 39 | 40 | 41 | @pytest.fixture 42 | def repo(make_repo2): 43 | return make_repo2('repo') 44 | -------------------------------------------------------------------------------- /runbot_merge/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import itertools 3 | import time 4 | 5 | 6 | def shorten(text_ish, length, cont='...'): 7 | """ If necessary, cuts-off the text or bytes input and appends ellipsis to 8 | signal the cutoff, such that the result is below the provided length 9 | (according to whatever "len" means on the text-ish so bytes or codepoints 10 | or code units). 11 | """ 12 | if len(text_ish or ()) <= length: 13 | return text_ish 14 | 15 | if isinstance(text_ish, bytes): 16 | cont = cont.encode('ascii') # whatever 17 | # add enough room for the ellipsis 18 | return text_ish[:length-len(cont)] + cont 19 | 20 | BACKOFF_DELAYS = (0.1, 0.2, 0.4, 0.8, 1.6) 21 | def backoff(func=None, *, delays=BACKOFF_DELAYS, exc=Exception): 22 | if func is None: 23 | return lambda func: backoff(func, delays=delays, exc=exc) 24 | 25 | for delay in itertools.chain(delays, [None]): 26 | try: 27 | return func() 28 | except exc: 29 | if delay is None: 30 | raise 31 | time.sleep(delay) 32 | 33 | def make_message(pr_dict): 34 | title = pr_dict['title'].strip() 35 | body = (pr_dict.get('body') or '').strip() 36 | return f'{title}\n\n{body}' if body else title 37 | -------------------------------------------------------------------------------- /runbot_populate/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import models 4 | -------------------------------------------------------------------------------- /runbot_populate/__manifest__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | { 3 | 'name': "runbot demo", 4 | 'summary': "Runbot demo data", 5 | 'description': "Runbot demo data", 6 | 'author': "Odoo SA", 7 | 'website': "http://runbot.odoo.com", 8 | 'category': 'Website', 9 | 'version': '1.0', 10 | 'depends': ['runbot'], 11 | 'demo': [ 12 | 'demo/runbot_demo.xml', 13 | ], 14 | 'license': 'LGPL-3', 15 | } 16 | -------------------------------------------------------------------------------- /runbot_populate/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import runbot 4 | --------------------------------------------------------------------------------