From 91d176f561647d41a6d05f4bede10e91354d90a1 Mon Sep 17 00:00:00 2001 From: Xavier-Do Date: Wed, 30 Mar 2022 08:11:45 +0200 Subject: [PATCH 01/30] [FIX] runbot: fix pull_info_failures --- runbot/models/runbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runbot/models/runbot.py b/runbot/models/runbot.py index c6430fad..53bbf191 100644 --- a/runbot/models/runbot.py +++ b/runbot/models/runbot.py @@ -276,7 +276,7 @@ class Runbot(models.AbstractModel): for pr_number, t in pull_info_failures.items(): if t + 15*60 < time.time(): _logger.warning('Removing %s from pull_info_failures', pr_number) - del self.pull_info_failures[pr_number] + del pull_info_failures[pr_number] return manager.get('sleep', default_sleep) From 48c975490675b4032bfa9298e526930f9c5159df Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 25 Mar 2022 11:16:28 +0100 Subject: [PATCH 02/30] [IMP] runbot_merge: acknowledge check command The command works, but the 'bot would always reply it was not allowed --- runbot_merge/models/pull_requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/runbot_merge/models/pull_requests.py b/runbot_merge/models/pull_requests.py index 429636e6..63a7e3ef 100644 --- a/runbot_merge/models/pull_requests.py +++ b/runbot_merge/models/pull_requests.py @@ -805,6 +805,7 @@ class PullRequests(models.Model): 'repository': self.repository.id, 'number': self.number, }) + ok = True elif command == 'review': if self.draft: msg = "Draft PRs can not be approved." From 1dcab0744df6e615a6456e63dda7f8c7b90b1eca Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 25 Mar 2022 11:17:05 +0100 Subject: [PATCH 03/30] [IMP] runbot_merge: remove dead code apparently at one point I wanted to provide an atom feed or something --- runbot_merge/controllers/dashboard.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/runbot_merge/controllers/dashboard.py b/runbot_merge/controllers/dashboard.py index 4686d815..dfaf0670 100644 --- a/runbot_merge/controllers/dashboard.py +++ b/runbot_merge/controllers/dashboard.py @@ -6,12 +6,9 @@ import pathlib import markdown import markupsafe import werkzeug.exceptions -from lxml import etree -from lxml.builder import ElementMaker from odoo.http import Controller, route, request -A = ElementMaker(namespace="http://www.w3.org/2005/Atom") LIMIT = 20 class MergebotDashboard(Controller): @route('/runbot_merge', auth="public", type="http", website=True) From 56898df93fd8ac004313c2777dfd9425da67fe98 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 3 Jun 2022 12:08:49 +0200 Subject: [PATCH 04/30] [ADD] runbot_merge: remote user provisioning New accounts endpoint such that the SSO can push new pre-configured users / employees directly. This lowers maintenance burden. Also remove one of the source partners from the merge test, as ordering seems wonky for unclear reasons leading to random failures of that test. --- conftest.py | 30 +++++- .../controllers/reviewer_provisioning.py | 101 ++++++++++++++++-- runbot_merge/models/res_partner.py | 1 + runbot_merge/tests/test_oddities.py | 38 ++++++- runbot_merge/tests/test_provisioning.py | 93 ++++++++++++++++ 5 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 runbot_merge/tests/test_provisioning.py diff --git a/conftest.py b/conftest.py index 57a240e1..33c72ffc 100644 --- a/conftest.py +++ b/conftest.py @@ -50,11 +50,14 @@ import functools import http.client import itertools import os +import pathlib +import pprint import random import re import socket import subprocess import sys +import tempfile import time import uuid import warnings @@ -271,7 +274,7 @@ class DbDict(dict): subprocess.run([ 'odoo', '--no-http', '--addons-path', self._adpath, - '-d', db, '-i', module, + '-d', db, '-i', module + ',auth_oauth', '--max-cron-threads', '0', '--stop-after-init', '--log-level', 'warn' @@ -334,17 +337,38 @@ def port(): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return s.getsockname()[1] +@pytest.fixture(scope='session') +def dummy_addons_path(): + with tempfile.TemporaryDirectory() as dummy_addons_path: + mod = pathlib.Path(dummy_addons_path, 'saas_worker') + mod.mkdir(0o700) + (mod / '__init__.py').write_bytes(b'') + (mod / '__manifest__.py').write_text(pprint.pformat({ + 'name': 'dummy saas_worker', + 'version': '1.0', + }), encoding='utf-8') + (mod / 'util.py').write_text("""\ +def from_role(_): + return lambda fn: fn +""", encoding='utf-8') + + yield dummy_addons_path + @pytest.fixture -def server(request, db, port, module): +def server(request, db, port, module, dummy_addons_path): log_handlers = [ 'odoo.modules.loading:WARNING', ] if not request.config.getoption('--log-github'): log_handlers.append('github_requests:WARNING') + addons_path = ','.join(map(str, [ + request.config.getoption('--addons-path'), + dummy_addons_path, + ])) p = subprocess.Popen([ 'odoo', '--http-port', str(port), - '--addons-path', request.config.getoption('--addons-path'), + '--addons-path', addons_path, '-d', db, '--max-cron-threads', '0', # disable cron threads (we're running crons by hand) *itertools.chain.from_iterable(('--log-handler', h) for h in log_handlers), diff --git a/runbot_merge/controllers/reviewer_provisioning.py b/runbot_merge/controllers/reviewer_provisioning.py index 75abf463..9d760dc4 100644 --- a/runbot_merge/controllers/reviewer_provisioning.py +++ b/runbot_merge/controllers/reviewer_provisioning.py @@ -1,18 +1,107 @@ # -*- coding: utf-8 -*- +import logging + +from odoo.http import Controller, request, route -from odoo import http -from odoo.http import request try: from odoo.addons.saas_worker.util import from_role except ImportError: def from_role(_): return lambda _: None - -class MergebotReviewerProvisioning(http.Controller): +_logger = logging.getLogger(__name__) +class MergebotReviewerProvisioning(Controller): + @from_role('accounts') + @route('/runbot_merge/users', type='json', auth='public') + def list_users(self): + env = request.env(su=True) + return [{ + 'github_login': u.github_login, + 'email': u.email, + } + for u in env['res.users'].search([]) + ] @from_role('accounts') - @http.route(['/runbot_merge/get_reviewers'], type='json', auth='public') + @route('/runbot_merge/provision', type='json', auth='public') + def provision_user(self, users): + _logger.info('Provisioning %s users: %s.', len(users), ', '.join(map( + '{email} ({github_login})'.format_map, + users + ))) + env = request.env(su=True) + Partners = env['res.partner'] + Users = env['res.users'] + + existing_partners = Partners.search([ + '|', ('email', 'in', [u['email'] for u in users]), + ('github_login', 'in', [u['github_login'] for u in users]) + ]) + _logger.info("Found %d existing matching partners.", len(existing_partners)) + partners = {} + for p in existing_partners: + if p.email: + # email is not unique, though we want it to be (probably) + current = partners.get(p.email) + if current: + _logger.warning( + "Lookup conflict: %r set on two partners %r and %r.", + p.email, current.display_name, p.display_name, + ) + else: + partners[p.email] = p + + if p.github_login: + # assume there can't be an existing one because github_login is + # unique, and should not be able to collide with emails + partners[p.github_login] = p + + if 'oauth_provider_id' in Users: + odoo_provider = env.ref('auth_oauth.provider_openerp', False) + if odoo_provider: + for new in users: + if 'sub' in new: + new['oauth_provider_id'] = odoo_provider.id + new['oauth_uid'] = new.pop('sub') + + to_create = [] + created = updated = 0 + for new in users: + # prioritise by github_login as that's the unique-est point of information + current = partners.get(new['github_login']) or partners.get(new['email']) or Partners + # entry doesn't have user -> create user + if not current.user_ids: + new['login'] = new['email'] + # entry has partner -> create user linked to existing partner + # (and update partner implicitly) + if current: + new['partner_id'] = current.id + to_create.append(new) + continue + + # otherwise update user (if there is anything to update) + user = current.user_ids + if len(user) != 1: + _logger.warning("Got %d users for partner %s.", len(user), current.display_name) + user = user[:1] + update_vals = { + k: v + for k, v in new.items() + if v not in ('login', 'email') + if v != (user[k] if k != 'oauth_provider_id' else user[k].id) + } + if update_vals: + user.write(update_vals) + updated += 1 + if to_create: + Users.create(to_create) + created = len(to_create) + + _logger.info("Provisioning: created %d updated %d.", created, updated) + return [created, updated] + + @from_role('accounts') + @route(['/runbot_merge/get_reviewers'], type='json', auth='public') def fetch_reviewers(self, **kwargs): reviewers = request.env['res.partner.review'].sudo().search([ '|', ('review', '=', True), ('self_review', '=', True) @@ -20,7 +109,7 @@ class MergebotReviewerProvisioning(http.Controller): return reviewers @from_role('accounts') - @http.route(['/runbot_merge/remove_reviewers'], type='json', auth='public', methods=['POST']) + @route(['/runbot_merge/remove_reviewers'], type='json', auth='public', methods=['POST']) def update_reviewers(self, github_logins, **kwargs): partners = request.env['res.partner'].sudo().search([('github_login', 'in', github_logins)]) partners.write({ diff --git a/runbot_merge/models/res_partner.py b/runbot_merge/models/res_partner.py index 3956af9c..d627b2f4 100644 --- a/runbot_merge/models/res_partner.py +++ b/runbot_merge/models/res_partner.py @@ -13,6 +13,7 @@ class CIText(fields.Char): class Partner(models.Model): _inherit = 'res.partner' + email = fields.Char(index=True) github_login = CIText() delegate_reviewer = fields.Many2many('runbot_merge.pull_requests') formatted_email = fields.Char(string="commit email", compute='_rfc5322_formatted') diff --git a/runbot_merge/tests/test_oddities.py b/runbot_merge/tests/test_oddities.py index b4a1fcf2..3ad1289b 100644 --- a/runbot_merge/tests/test_oddities.py +++ b/runbot_merge/tests/test_oddities.py @@ -1,11 +1,10 @@ +import requests + from utils import Commit, to_pr def test_partner_merge(env): p_src = env['res.partner'].create({ - 'name': 'kfhsf', - 'github_login': 'tyu' - }) | env['res.partner'].create({ 'name': "xxx", 'github_login': 'xxx' }) @@ -97,3 +96,36 @@ def test_message_desync(env, project, make_repo, users, setreviewers, config): assert st.message.startswith('title\n\nbody'),\ "the stored PR message should have been ignored when staging" assert st.parents == [m, c], "check the staging's ancestry is the right one" + +def test_unreviewer(env, project, port): + repo = env['runbot_merge.repository'].create({ + 'project_id': project.id, + 'name': 'a_test_repo', + 'status_ids': [(0, 0, {'context': 'status'})] + }) + p = env['res.partner'].create({ + 'name': 'George Pearce', + 'github_login': 'emubitch', + 'review_rights': [(0, 0, {'repository_id': repo.id, 'review': True})] + }) + + r = requests.post(f'http://localhost:{port}/runbot_merge/get_reviewers', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': {}, + }) + r.raise_for_status() + assert 'error' not in r.json() + assert r.json()['result'] == ['emubitch'] + + r = requests.post(f'http://localhost:{port}/runbot_merge/remove_reviewers', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': {'github_logins': ['emubitch']}, + }) + r.raise_for_status() + assert 'error' not in r.json() + + assert p.review_rights == env['res.partner.review'] diff --git a/runbot_merge/tests/test_provisioning.py b/runbot_merge/tests/test_provisioning.py new file mode 100644 index 00000000..debfa3b5 --- /dev/null +++ b/runbot_merge/tests/test_provisioning.py @@ -0,0 +1,93 @@ +import pytest +import requests + +GEORGE = { + 'name': "George Pearce", + 'email': 'george@example.org', + 'github_login': 'emubitch', + 'sub': '19321102' +} +def test_basic_provisioning(env, port): + + r = provision_user(port, [GEORGE]) + assert r == [1, 0] + + g = env['res.users'].search([('login', '=', GEORGE['email'])]) + assert g.partner_id.name == GEORGE['name'] + assert g.partner_id.github_login == GEORGE['github_login'] + assert g.oauth_uid == GEORGE['sub'] + + # repeated provisioning should be a no-op + r = provision_user(port, [GEORGE]) + assert r == [0, 0] + + # the email (real login) should be the determinant, any other field is + # updatable + r = provision_user(port, [{**GEORGE, 'name': "x"}]) + assert r == [0, 1] + + r = provision_user(port, [dict(GEORGE, name="x", github_login="y", sub="42")]) + assert r == [0, 1] + + # can't fail anymore because github_login now used to look up the existing + # user + # with pytest.raises(Exception): + # provision_user(port, [{ + # 'name': "other@example.org", + # 'email': "x", + # 'github_login': "y", + # 'sub': "42" + # }]) + + r = provision_user(port, [dict(GEORGE, active=False)]) + assert r == [0, 1] + assert not env['res.users'].search([('login', '=', GEORGE['email'])]) + assert env['res.partner'].search([('email', '=', GEORGE['email'])]) + +def test_upgrade_partner(env, port): + # If a partner exists for a github login (and / or email?) it can be + # upgraded by creating a user for it + p = env['res.partner'].create({ + 'name': GEORGE['name'], + 'email': GEORGE['email'], + }) + r = provision_user(port, [GEORGE]) + assert r == [1, 0] + assert p.user_ids.read(['email', 'github_login', 'oauth_uid']) == [{ + 'id': p.user_ids.id, + 'github_login': GEORGE['github_login'], + 'oauth_uid': GEORGE['sub'], + 'email': GEORGE['email'], + }] + + p.user_ids.unlink() + p.unlink() + + p = env['res.partner'].create({ + 'name': GEORGE['name'], + 'github_login': GEORGE['github_login'], + }) + r = provision_user(port, [GEORGE]) + assert r == [1, 0] + assert p.user_ids.read(['email', 'github_login', 'oauth_uid']) == [{ + 'id': p.user_ids.id, + 'github_login': GEORGE['github_login'], + 'oauth_uid': GEORGE['sub'], + 'email': GEORGE['email'], + }] + + p.user_ids.unlink() + p.unlink() + +def provision_user(port, users): + r = requests.post(f'http://localhost:{port}/runbot_merge/provision', json={ + 'jsonrpc': '2.0', + 'id': None, + 'method': 'call', + 'params': {'users': users}, + }) + r.raise_for_status() + json = r.json() + assert 'error' not in json + + return json['result'] From 2204c0410a449a4dc1b021354f9e88c7ed33fb12 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Tue, 7 Jun 2022 15:49:52 +0200 Subject: [PATCH 05/30] [IMP] mergebot, forwardbot: various UI bits - code in the various menus added over time through the UI (queues, configuration, ...) - update / improve PR layout a tick - fix "outstanding forward ports" count on the dashboard - improve hover title / help on dashboard - add date of last modification (usually date of success / failure) - make casing more coherent (everything lowercase) - add explicit note that UTC date on staged at label is staged at datetime - rediscover yet again that the staging information is when hovering on the staging *except the staged at label* - improve `PullRequest.unstage` to always insert the PR at the start of the reason when cancelling the staging, for clarity / traceability Closes #560, closes #609 --- forwardport/__manifest__.py | 1 + forwardport/data/queues.xml | 51 +++++++++++++++ forwardport/data/views.xml | 4 ++ runbot_merge/__manifest__.py | 2 + runbot_merge/changelog/2022-06/ui.md | 6 ++ runbot_merge/controllers/__init__.py | 6 +- runbot_merge/models/pull_requests.py | 10 +-- runbot_merge/views/configuration.xml | 43 ++++++++++++ runbot_merge/views/mergebot.xml | 79 +++++++--------------- runbot_merge/views/queues.xml | 97 ++++++++++++++++++++++++++++ runbot_merge/views/templates.xml | 14 ++-- 11 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 forwardport/data/queues.xml create mode 100644 runbot_merge/changelog/2022-06/ui.md create mode 100644 runbot_merge/views/configuration.xml create mode 100644 runbot_merge/views/queues.xml diff --git a/forwardport/__manifest__.py b/forwardport/__manifest__.py index 185f8256..b86c425e 100644 --- a/forwardport/__manifest__.py +++ b/forwardport/__manifest__.py @@ -8,6 +8,7 @@ 'data/security.xml', 'data/crons.xml', 'data/views.xml', + 'data/queues.xml', ], 'license': 'LGPL-3', } diff --git a/forwardport/data/queues.xml b/forwardport/data/queues.xml new file mode 100644 index 00000000..ba011344 --- /dev/null +++ b/forwardport/data/queues.xml @@ -0,0 +1,51 @@ + + + Forward port batches + forwardport.batches + {'active_test': False} + + + Forward port batches + forwardport.batches + + + + + + + + + Forward port batch + forwardport.batches + +
+ + + + +
+
+
+ + + Followup Updates + forwardport.updates + + + Followup Updates + forwardport.updates + + + + + + + + + + +
diff --git a/forwardport/data/views.xml b/forwardport/data/views.xml index ff7f66c9..b7000892 100644 --- a/forwardport/data/views.xml +++ b/forwardport/data/views.xml @@ -12,6 +12,7 @@
@@ -175,6 +176,9 @@ runbot_merge.pull_requests + + + diff --git a/runbot_merge/__manifest__.py b/runbot_merge/__manifest__.py index 8375a3d8..9f44e106 100644 --- a/runbot_merge/__manifest__.py +++ b/runbot_merge/__manifest__.py @@ -10,6 +10,8 @@ 'views/res_partner.xml', 'views/runbot_merge_project.xml', 'views/mergebot.xml', + 'views/queues.xml', + 'views/configuration.xml', 'views/templates.xml', 'models/project_freeze/views.xml', ], diff --git a/runbot_merge/changelog/2022-06/ui.md b/runbot_merge/changelog/2022-06/ui.md new file mode 100644 index 00000000..33a1667c --- /dev/null +++ b/runbot_merge/changelog/2022-06/ui.md @@ -0,0 +1,6 @@ +IMP: various UI items + +- more clearly differentiate between "pending" and "unknown" statuses on stagings +- fix "outstanding forward ports" count +- add date of staging last modification (= success / failure instant) +- correctly retrieve and include fast-forward and unstaging reasons diff --git a/runbot_merge/controllers/__init__.py b/runbot_merge/controllers/__init__.py index 199b4801..d96d703d 100644 --- a/runbot_merge/controllers/__init__.py +++ b/runbot_merge/controllers/__init__.py @@ -171,11 +171,7 @@ def handle_pr(env, event): return "It's my understanding that closed/merged PRs don't get sync'd" if pr_obj.state == 'ready': - pr_obj.unstage( - "PR %s updated by %s", - pr_obj.display_name, - event['sender']['login'] - ) + pr_obj.unstage("updated by %s", event['sender']['login']) _logger.info( "PR %s updated to %s by %s, resetting to 'open' and squash=%s", diff --git a/runbot_merge/models/pull_requests.py b/runbot_merge/models/pull_requests.py index 63a7e3ef..76407ebf 100644 --- a/runbot_merge/models/pull_requests.py +++ b/runbot_merge/models/pull_requests.py @@ -846,7 +846,7 @@ class PullRequests(models.Model): 'pull_request': self.number, 'message': "PR priority reset to 1, as pull requests with priority 0 ignore review state.", }) - self.unstage("unreview (r-) by %s", author.github_login) + self.unstage("unreviewed (r-) by %s", author.github_login) ok = True else: msg = "r- makes no sense in the current PR state." @@ -1349,7 +1349,7 @@ class PullRequests(models.Model): # else remove this batch from the split b.split_id = False - self.staging_id.cancel(reason, *args) + self.staging_id.cancel('%s ' + reason, self.display_name, *args) def _try_closing(self, by): # ignore if the PR is already being updated in a separate transaction @@ -1369,11 +1369,7 @@ class PullRequests(models.Model): ''', [self.id]) self.env.cr.commit() self.modified(['state']) - self.unstage( - "PR %s closed by %s", - self.display_name, - by - ) + self.unstage("closed by %s", by) return True # state changes on reviews diff --git a/runbot_merge/views/configuration.xml b/runbot_merge/views/configuration.xml new file mode 100644 index 00000000..70e8d710 --- /dev/null +++ b/runbot_merge/views/configuration.xml @@ -0,0 +1,43 @@ + + + CI / statuses overrides + res.partner.override + + + Overrides List + res.partner.override + + + + + + + + + + + Review Rights + res.partner.review + {'search_default_group_by_repository': True} + + + Review Rights + res.partner.review + + + + + + + + + + + + + + diff --git a/runbot_merge/views/mergebot.xml b/runbot_merge/views/mergebot.xml index f8922130..34472a5a 100644 --- a/runbot_merge/views/mergebot.xml +++ b/runbot_merge/views/mergebot.xml @@ -97,10 +97,10 @@ - + @@ -108,6 +108,8 @@ + + @@ -205,66 +207,33 @@ - - PRs to fetch - runbot_merge.fetch_job - tree - {'default_active': True} + + Commit Statuses + runbot_merge.commit + tree,form - - Fetches Search - runbot_merge.fetch_job - - - - - - - - - - Fetches Tree - runbot_merge.fetch_job + + commits list + runbot_merge.commit - - - - - - - - CI / statuses overrides - res.partner.override - - - Overrides List - res.partner.override - - - - - + + - - - - - - + + + + diff --git a/runbot_merge/views/queues.xml b/runbot_merge/views/queues.xml new file mode 100644 index 00000000..a72571f3 --- /dev/null +++ b/runbot_merge/views/queues.xml @@ -0,0 +1,97 @@ + + + + Splits + runbot_merge.split + + + Splits + runbot_merge.split + + + + + + + + + + Feedback + runbot_merge.pull_requests.feedback + + + Feedback + runbot_merge.pull_requests.feedback + + + + + + + + + + + + Tagging + runbot_merge.pull_requests.tagging + + + Tagging + runbot_merge.pull_requests.tagging + + + + + + + + + + + + PRs to fetch + runbot_merge.fetch_job + tree + {'default_active': True} + + + Fetches Search + runbot_merge.fetch_job + + + + + + + + + + Fetches Tree + runbot_merge.fetch_job + + + + + + + + + + + + + + diff --git a/runbot_merge/views/templates.xml b/runbot_merge/views/templates.xml index 49e475b3..fab3b1e6 100644 --- a/runbot_merge/views/templates.xml +++ b/runbot_merge/views/templates.xml @@ -135,11 +135,15 @@ visible-lg-block - Cancelled: - Fast Forward Failed - + fast forward failed + last status + -
  • + + + + at Z +
    • @@ -152,7 +156,7 @@
      • From a5fae548fedf8617b9ddf450cc073d15548674e5 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 8 Jun 2022 15:43:28 +0200 Subject: [PATCH 07/30] [IMP] runbot_merge: extract dashboard pr linking Extract the creation of a PR link (to github) as a dedicated template for easier updates, and to use `display_name` everywhere (instead of reimplementing it by name). Also implement support for repo-level groups setting for information hiding. Fixes #590 --- runbot_merge/views/templates.xml | 49 ++++++++++++++------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/runbot_merge/views/templates.xml b/runbot_merge/views/templates.xml index c0a86866..2f2bcefb 100644 --- a/runbot_merge/views/templates.xml +++ b/runbot_merge/views/templates.xml @@ -10,6 +10,18 @@ + + + +