Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
9ca62d8
[IMP] runbot_merge: replace details-based dropdown by popovers
xmo-odoo Mar 25, 2026
95509c2
[FIX] runbot_merge: SerializationFailure on commit handling
xmo-odoo Mar 25, 2026
b46cfdc
[FIX] runbot_merge: return a 200 on PR dupe
xmo-odoo Mar 25, 2026
f5d7648
[ADD] runbot_merge: batch locator to the branch list
xmo-odoo Mar 25, 2026
e739d6c
[FIX] runbot_merge: don't generate empty splits
xmo-odoo Mar 27, 2026
48f59d5
[IMP] runbot_merge: handle empty splits during staging
xmo-odoo Mar 27, 2026
11e246e
[ADD] runbot_merge: CI requests
xmo-odoo Mar 30, 2026
2372bcb
[FIX] runbot_merge: github_login change for employee
xmo-odoo Mar 30, 2026
d7c35a0
[FIX] runbot_merge: uncalled methods
xmo-odoo Mar 31, 2026
3654809
[FIX] runbot_merge: leftover print
xmo-odoo Mar 31, 2026
6a9906c
[FIX] runbot_merge: structure of synthetic `edited` event
xmo-odoo Mar 31, 2026
5e554ef
[FIX] runbot_merge: logging call missing parameters
xmo-odoo Mar 31, 2026
13bb26b
[FIX] runbot_merge: incorrect attribute in log call
xmo-odoo Mar 31, 2026
d9229cd
[FIX] runbot_merge: markup in PR message
xmo-odoo Mar 31, 2026
85f9377
[FIX] runbot_merge: skipmerge should not apply cross-PR
xmo-odoo Mar 31, 2026
a452da5
[FIX] runbot_merge: consistency fix
xmo-odoo Mar 31, 2026
32e0e5e
[FIX] runbot_merge: missing continue on fw condition fail
xmo-odoo Mar 31, 2026
63bf1aa
[IMP] runbot_merge: tx controller
xmo-odoo Mar 31, 2026
4098730
[FIX] runbot_merge: handling of `closed` flag on pr write
xmo-odoo Mar 31, 2026
5479bdd
[FIX] runbot_merge: workaround race in a test
xmo-odoo Mar 31, 2026
84e8902
[FIX] runbot_merge: stop treating errors during sync as innocuous
xmo-odoo Mar 31, 2026
95a55c3
[FIX] runbot_merge: joining batches doesn't make sense, just log them
xmo-odoo Apr 1, 2026
7981eae
[FIX] runbot_merge: branch name consistency
xmo-odoo Apr 1, 2026
8340593
[FIX] runbot_merge:
xmo-odoo Apr 1, 2026
b8ed20c
[FIX] runbot_merge: don't overwrite `login` when delegating
xmo-odoo Apr 1, 2026
c97d487
[FIX] runbot_merge: merge errors should have a PR as arg0
xmo-odoo Apr 1, 2026
529ffb3
[IMP] runbot_merge: square up string shortening
xmo-odoo Apr 1, 2026
ba95936
[FIX] runbot_merge: don't break if a large PR has no merge method
xmo-odoo Apr 1, 2026
3bc08c5
[FIX] runbot_merge: location of the git-fw script
xmo-odoo Apr 1, 2026
3155c3f
[IMP] runbot_merge: deduplicate creation of enum types
xmo-odoo Apr 1, 2026
4927bf4
[IMP] runbot_merge: integrate stagings using git
xmo-odoo Apr 7, 2026
83380a4
[IMP] runbot_merge: when forwarding PR, ignore commits count from github
xmo-odoo Apr 8, 2026
6651a18
[IMP] runbot_merge: double-check 0-commit PRs
xmo-odoo Apr 8, 2026
9435ebc
[FIX] runbot_merge: prevent marking PRs as errored when skipping vali…
vib-adhoc Apr 8, 2026
3e61b3e
[FIX] runbot_merge: add delays around disabled hooks code
xmo-odoo Apr 22, 2026
d5ff2a5
[IMP] runbot_merge: delay ops to improve event sequencing
xmo-odoo Apr 22, 2026
66df42f
[FIX] runbot_merge: status code
xmo-odoo Apr 22, 2026
f0f415b
[FIX] runbot_merge: gh added more stuff to rules error message
xmo-odoo Apr 22, 2026
b639130
[FIX] runbot_merge: handle missing source target head on fw conflict
xmo-odoo Apr 23, 2026
179d2e6
[IMP] runbot_merge: add subject to feedback cron notification
xmo-odoo Apr 23, 2026
f78ee61
[IMP] runbot_merge: limit the amount of feedback items sent at once
xmo-odoo Apr 23, 2026
002e954
[IMP] runbot_merge: split feedback by batches of 100
xmo-odoo Apr 23, 2026
6bb375a
[IMP] runbot_merge: don't recompute statuses for merged PRs
xmo-odoo Apr 24, 2026
717fac6
[FIX] runbot_merge: test for 9435ebc911b85cfc9692b8d010b8ded80e3ec19e
xmo-odoo Apr 24, 2026
f3b359e
[IMP] git-fw: only require a pr number
xmo-odoo Apr 24, 2026
2292572
[IMP] git-fw: disable asking for credentials
xmo-odoo Apr 24, 2026
2e9ef3f
[FIX] git-fw: handling if the fw branch is not already tracked
xmo-odoo Apr 24, 2026
4656dc2
[IMP] git-fw: check if there's a different version which can be downl…
xmo-odoo Apr 24, 2026
6910bbe
[FIX] runbot_merge: correctly close PRs in `check`
xmo-odoo Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,9 @@ def disable_hooks(self) -> typing.Iterator[None]:
for hook in hook_urls:
r = sess.patch(hook, json={'active': False})
assert r.ok, r.text
wait_for_hook()
yield
wait_for_hook()
for hook in reversed(hook_urls):
r = sess.patch(hook, json={'active': True})
assert r.ok, r.text
Expand Down
15 changes: 14 additions & 1 deletion mergebot_test_utils/saas_worker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
import threading

import psycopg2
import requests.sessions

import odoo
from odoo import models
from odoo import models, fields

from . import auth_util

requests.sessions.Session = auth_util.SaasSession

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -49,3 +54,11 @@ def _process_jobs(cls, db_name):
finally:
if hasattr(t, 'dbname'):
del t.dbname


class SaasCalls(models.Model):
_name = _description = 'saas.calls'

method = fields.Char()
url = fields.Char()
body = fields.Binary(attachment=False)
3 changes: 3 additions & 0 deletions mergebot_test_utils/saas_worker/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
'name': 'dummy saas_worker',
'version': '1.0',
'license': 'BSD-0-Clause',
'data': [
'access.xml',
]
}
10 changes: 10 additions & 0 deletions mergebot_test_utils/saas_worker/access.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<odoo>
<record id="access_calls" model="ir.model.access">
<field name="name">Access to saas calls</field>
<field name="model_id" ref="model_saas_calls"/>
<field name="perm_read">1</field>
<field name="perm_create">0</field>
<field name="perm_write">0</field>
<field name="perm_unlink">0</field>
</record>
</odoo>
44 changes: 44 additions & 0 deletions mergebot_test_utils/saas_worker/auth_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import io
import threading
import urllib.parse

import requests.adapters
import requests.auth
import requests.sessions

import odoo


class SaasAdapter(requests.adapters.BaseAdapter):
def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
res = requests.Response()
res.request = request
res.status_code = 204
res.raw = io.BytesIO()
return res

def close(self) -> None:
pass


class SaasSession(requests.sessions.Session):
def __init__(self):
super().__init__()
self.mount('saas://', SaasAdapter())


class SaasAuth(requests.auth.AuthBase):
def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
dbname = threading.current_thread().dbname
db = odoo.sql_db.db_connect(dbname)
with db.cursor() as cr:
env = odoo.api.Environment(cr, 1, {})
env['saas.calls'].create({
'method': request.method,
'url': request.url,
'body': request.body,
})
request.url = urllib.parse.urlsplit(request.url)\
._replace(scheme='saas')\
.geturl()
return request
4 changes: 3 additions & 1 deletion runbot_merge/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
'name': 'merge bot',
'version': '1.19',
'version': '1.20',
'depends': ['contacts', 'mail', 'website'],
'data': [
'security/security.xml',
Expand All @@ -26,6 +26,8 @@
'models/commands/runbot_merge.acls.csv',
'models/commands/views.xml',
'models/crons/validate.xml',
'models/crons/status_request.xml',
'models/crons/check_commits.xml',
],
'assets': {
'web._assets_primary_variables': [
Expand Down
27 changes: 23 additions & 4 deletions runbot_merge/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import datetime, timedelta
from typing import Callable

import psycopg2.errors
import sentry_sdk
from werkzeug.exceptions import NotFound, UnprocessableEntity

Expand Down Expand Up @@ -74,7 +75,7 @@ def stagings_for_commits(self, **kw):
@route('/runbot_merge/stagings/<int:staging>', auth='none', type='http', methods=['GET'])
def prs_for_staging(self, staging):
staging = request.env(user=1)['runbot_merge.stagings'].browse(staging).sudo()
if not staging.exists:
if not staging.exists():
raise NotFound()

return request.make_json_response(staging_dict(staging))
Expand Down Expand Up @@ -173,7 +174,15 @@ def _format(self, request):

def handle_pr(env, event):
pr = event['pull_request']
squash = pr['commits'] == 1
squash = False
check_commits = lambda _: None
match pr['commits']:
case 0:
check_commits = lambda pr_obj: env['runbot_merge.pull_requests.check_commits'].create({
'pull_request_id': pr_obj.id,
})
case 1:
squash = True
r = pr['base']['repo']['full_name']

if event['action'] in [
Expand All @@ -192,6 +201,7 @@ def handle_pr(env, event):
('squash', '!=', squash),
]):
pr.squash = squash
check_commits(pr)

return Response(
status=200,
Expand Down Expand Up @@ -267,6 +277,7 @@ def find(target):
if updates:
# copy because it updates the `updates` dict internally
pr_obj.write(dict(updates))
check_commits(pr_obj)
return Response(
status=200,
mimetype="text/plain",
Expand Down Expand Up @@ -320,8 +331,13 @@ def find(target):
author = env['res.partner'].search([('github_login', '=', author_name)], limit=1)
if not author:
env['res.partner'].create({'name': author_name, 'github_login': author_name})
pr_obj = env['runbot_merge.pull_requests']._from_gh(pr)
return Response(status=201, mimetype="text/plain", response=f"Tracking PR as {pr_obj.display_name}")
try:
pr_obj = env['runbot_merge.pull_requests']._from_gh(pr)
check_commits(pr_obj)
return Response(status=201, mimetype="text/plain", response=f"Tracking PR as {pr_obj.display_name}")
except psycopg2.errors.UniqueViolation:
env.cr.rollback()
return Response(status=200, mimetype="text/plain", response="Already known")

pr_obj = env['runbot_merge.pull_requests']._get_or_schedule(r, pr['number'], closing=event['action'] == 'closed')
if not pr_obj:
Expand Down Expand Up @@ -370,11 +386,13 @@ def find(target):
'head': pr['head']['sha'],
'squash': squash,
})
check_commits(pr_obj)
return Response(mimetype="text/plain", response=f'Updated to {pr_obj.head}')

if event['action'] not in ('closed', 'reopened'):
if pr_obj.squash != squash:
pr_obj.squash = squash
check_commits(pr_obj)

if event['action'] == 'ready_for_review':
pr_obj.draft = False
Expand Down Expand Up @@ -422,6 +440,7 @@ def find(target):
'head': pr['head']['sha'],
'squash': pr['commits'] == 1,
})
check_commits(pr_obj)

return Response(mimetype="text/plain", response=f'Reopened {pr_obj.display_name}')

Expand Down
4 changes: 2 additions & 2 deletions runbot_merge/controllers/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ def merge_commit(self, commit_hash, repository, branch, project="RD"):

target = request.env["runbot_merge.branch"].sudo().search([
("name", "=", branch),
("project_id", "=", project)
("project_id.name", "=", project)
])
if not target:
return {"error": "Target branch %s:%s not found" % (branch, target)}
return {"error": f"Target branch {project}:{branch} not found"}

patch = request.env["runbot_merge.patch"].sudo().create({
"repository": repository_id.id,
Expand Down
28 changes: 27 additions & 1 deletion runbot_merge/controllers/reviewer_provisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ def provision_user(self, users):
# unique, and should not be able to collide with emails
partners[p.github_login.casefold()] = p

existing_users = Users.with_context(active_test=False).search([
('partner_id', 'not in', existing_partners.ids),
'|', ('login', 'in', [u['email'] for u in users]),
('oauth_uid', 'in', [s for u in users if (s := u.get('sub'))]),
])
_logger.info("Found %d existing matching users.", len(existing_users))
users_map = {}
for u in existing_users:
users_map[u.login] = u
users_map[u.oauth_uid] = u

portal = env.ref('base.group_portal')
internal = env.ref('base.group_user')
odoo_provider = env.ref('auth_oauth.provider_openerp')
Expand All @@ -68,7 +79,12 @@ def provision_user(self, users):
new['oauth_uid'] = new.pop('sub')

# prioritise by github_login as that's the unique-est point of information
current = partners.get(new['github_login'].casefold()) or partners.get(new['email']) or Partners
current = partners.get(new['github_login'].casefold()) \
or partners.get(new['email']) \
or (users_map.get(new['email'])
or users_map.get(new.get('oauth_uid'))
or Users
).partner_id
if not current.active:
to_activate |= current

Expand Down Expand Up @@ -127,6 +143,7 @@ def provision_user(self, users):
new['partner_id'] = current.id
to_create.append(new)


created = len(to_create)
if to_create:
# only create 100 users at a time to avoid request timeout
Expand All @@ -152,7 +169,16 @@ def fetch_reviewers(self, **kwargs):
'/runbot_merge/remove_reviewers', # deprecated URL
], type='json', auth='public', methods=['POST'])
def disable_users(self, github_logins, **kwargs):
_logger.info("deprovisioning %s: %s")
partners = request.env['res.partner'].sudo().search([('github_login', 'in', github_logins)])
_logger.info(
"deprovisioning %s: %s",
github_logins,
[
f"{p.display_name} ({', '.join(p.mapped('user_ids.login'))})"
for p in partners
]
)
partners.write({
'email': False,
'parent_id': False,
Expand Down
49 changes: 49 additions & 0 deletions runbot_merge/fw_tests/test_conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,55 @@ def test_conflict(env, config, make_repo, users):
'i': 'a',
}

def test_conflict_unknown_root_head(
env, config, make_repo,
):
"""Very specific implementation detail: on conflict we try to infer
modify/delete conflicts in order to mark the files git reintrodued, to do so
we try to list the files modified by the original PR, and that requires
having the head of its target branch locally...
"""
fw_cron = env.ref('runbot_merge.port_forward')
fw_cron.active = False
prod, _other = make_basic(env, config, make_repo, statuses="default")

with prod:
# generate conflict: remove f from b, while updating f in a
prod.make_commits(
'b', Commit('33', tree={'g': 'c'}, reset=True),
ref='heads/b'
)
[p_0] = prod.make_commits(
'a', Commit('p_0', tree={'f': 'xxx'}),
ref='heads/a_pr'
)
pr = prod.make_pr(target='a', head='a_pr')
prod.post_status(p_0, 'success')
pr.post_comment('hansen r+', config['role_reviewer']['token'])
env.run_crons()
with prod:
prod.post_status('staging.a', 'success')
env.run_crons()

# there should be no forward port (yet)
assert env['runbot_merge.pull_requests'].search_count([]) == 1
# there should be an fw job
assert env['forwardport.batches'].search_count([]) == 1

# add a commit to a which the mergebot should not know about
with prod:
prod.make_commits(
'a',
Commit('X', tree={'g': 'fkdshf'}),
ref='heads/a',
)

fw_cron.active = True
fw_cron.trigger()
env.run_crons()

assert env['runbot_merge.pull_requests'].search_count([]) == 2

def test_massive_conflict(env, config, make_repo):
"""If the conflict is large enough, the commit message may exceed ARG_MAX
and trigger E2BIG.
Expand Down
10 changes: 0 additions & 10 deletions runbot_merge/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,6 @@ def fetchone(self, repo, branch: str) -> str:
"""
return next(self.fetch_heads(repo, f"refs/heads/{branch}"))

def remote_head(self, repo, branch: str) -> str:
r = self.stdout().with_config(check=True, encoding="utf-8").ls_remote(
source_url(repo),
f'refs/heads/{branch}',
)
assert r.stdout.count('\n') == 1, f"expected single line, got {r.stdout}"
# The output is in the format: <oid> TAB <ref> LF
head, _ = r.stdout.split('\t', 1)
return head

def get_tree(self, rev: str) -> str:
return self.stdout().with_config(check=True, encoding="utf-8")\
.rev_parse(f'{rev}^{{tree}}')\
Expand Down
5 changes: 5 additions & 0 deletions runbot_merge/migrations/17.0.1.20/pre-migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def migrate(cr, _version):
cr.execute("""
ALTER TYPE runbot_merge_acls_state_type
RENAME TO runbot_merge_acls_effect_type;
""")
3 changes: 2 additions & 1 deletion runbot_merge/models/backport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def action_apply(self) -> dict:

old_map = self.pr_id.commits_map
self.pr_id.commits_map = "{}"
conflict, head = self.pr_id._create_port_branch(repo, self.target, forward=False)
conflict, head, n = self.pr_id._create_port_branch(repo, self.target, forward=False)
self.pr_id.commits_map = old_map

if conflict:
Expand Down Expand Up @@ -122,6 +122,7 @@ def action_apply(self) -> dict:
# the backport's own forwardport should stop right before the
# original PR by default
limit_id=branches[source_idx - 1],
squash=n==1,
)
_logger.info("Created backport %s for %s", backport.display_name, self.pr_id.display_name)

Expand Down
Loading