Skip to content

Commit

Permalink
[IMP] runbot_merge: add optional statuses on PRs
Browse files Browse the repository at this point in the history
If a status is defined as `optional`, then the PR is considered valid
if the status is never sent, but *if* the status is sent then it
becomes required.

Note that as ever this is a per-commit requirement, so it's mostly
useful for conditional statuses.

Fixes #1062
  • Loading branch information
xmo-odoo committed Feb 25, 2025
1 parent 98bb01e commit abf1298
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 5 deletions.
2 changes: 1 addition & 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.15',
'version': '1.16',
'depends': ['contacts', 'mail', 'website'],
'data': [
'security/security.xml',
Expand Down
12 changes: 12 additions & 0 deletions runbot_merge/migrations/17.0.1.16/pre-migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
def migrate(cr, _version):
cr.execute("""
ALTER TABLE runbot_merge_repository_status
ALTER COLUMN prs TYPE varchar;
UPDATE runbot_merge_repository_status
SET prs =
CASE prs
WHEN 'true' THEN 'required'
ELSE 'ignored'
END;
""")
35 changes: 32 additions & 3 deletions runbot_merge/models/pull_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import re
import time
import typing
from enum import IntEnum
from functools import reduce
from operator import itemgetter
Expand Down Expand Up @@ -42,9 +43,33 @@ class StatusConfiguration(models.Model):
context = fields.Char(required=True)
repo_id = fields.Many2one('runbot_merge.repository', required=True, ondelete='cascade')
branch_filter = fields.Char(help="branches this status applies to")
prs = fields.Boolean(string="Applies to pull requests", default=True)
prs = fields.Selection([
('required', 'Required'),
('optional', 'Optional'),
('ignored', 'Ignored'),
],
default='required',
required=True,
string="Applies to pull requests",
column_type=enum(_name, 'prs'),
)
stagings = fields.Boolean(string="Applies to stagings", default=True)

def _auto_init(self):
for field in self._fields.values():
if not isinstance(field, fields.Selection) or field.column_type[0] == 'varchar':
continue

t = field.column_type[1]
self.env.cr.execute("SELECT 1 FROM pg_type WHERE typname = %s", [t])
if not self.env.cr.rowcount:
self.env.cr.execute(
f"CREATE TYPE {t} AS ENUM %s",
[tuple(s for s, _ in field.selection)]
)

super()._auto_init()

def _for_branch(self, branch):
assert branch._name == 'runbot_merge.branch', \
f'Expected branch, got {branch}'
Expand All @@ -55,12 +80,16 @@ def _for_branch(self, branch):
def _for_pr(self, pr):
assert pr._name == 'runbot_merge.pull_requests', \
f'Expected pull request, got {pr}'
return self._for_branch(pr.target).filtered('prs')
return self._for_branch(pr.target).filtered(lambda p: p.prs != 'ignored')
def _for_staging(self, staging):
assert staging._name == 'runbot_merge.stagings', \
f'Expected staging, got {staging}'
return self._for_branch(staging.target).filtered('stagings')

@property
def _default_pr_state(self) -> typing.Literal['pending', 'success']:
return 'pending' if self.prs == 'required' else 'success'

class Repository(models.Model):
_name = _description = 'runbot_merge.repository'
_order = 'sequence, id'
Expand Down Expand Up @@ -1243,7 +1272,7 @@ def _compute_statuses(self):

st = 'success'
for ci in pr.repository.status_ids._for_pr(pr):
v = (statuses.get(ci.context) or {'state': 'pending'})['state']
v = (statuses.get(ci.context) or {'state': ci._default_pr_state})['state']
if v in ('error', 'failure'):
st = 'failure'
break
Expand Down
2 changes: 1 addition & 1 deletion runbot_merge/tests/test_by_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def _setup_statuses(project, repo):
'branch_filter': [('id', '=', project.branch_ids.id)]
}),
(0, 0, {'context': 'pr', 'stagings': False}),
(0, 0, {'context': 'staging', 'prs': False}),
(0, 0, {'context': 'staging', 'prs': 'ignored'}),
]

@pytest.mark.usefixtures('_setup_statuses')
Expand Down
49 changes: 49 additions & 0 deletions runbot_merge/tests/test_statuses_optional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from utils import Commit, to_pr


def test_basic(env, project, make_repo, users, setreviewers, config):
repository = make_repo('repo')
env['runbot_merge.repository'].create({
'project_id': project.id,
'name': repository.name,
'status_ids': [(0, 0, {'context': 'l/int', 'prs': 'optional'})]
})
setreviewers(*project.repo_ids)
env['runbot_merge.events_sources'].create({'repository': repository.name})

with repository:
m = repository.make_commits(None, Commit('root', tree={'a': '1'}), ref='heads/master')

repository.make_commits(m, Commit('pr', tree={'a': '2'}), ref='heads/change')
pr = repository.make_pr(target='master', title='super change', head='change')
env.run_crons()

# if an optional status is never received then the PR is valid
pr_id = to_pr(env, pr)
assert pr_id.state == 'validated'

# If a run has started, then the PR is pending (not considered valid), this
# limits the odds of merging a PR even though it's not valid, as long as the
# optional status starts running before all the required statuses arrive
# (with a success result).
with repository:
repository.post_status(pr.head, 'pending', 'l/int')
env.run_crons()
assert pr_id.state == 'opened'

# If the status fails, then the PR is rejected.
with repository:
repository.post_status(pr.head, 'failure', 'l/int')
env.run_crons()
assert pr_id.state == 'opened'

# re-run the job / fix the PR
with repository:
repository.post_status(pr.head, 'pending', 'l/int')
env.run_crons()
assert pr_id.state == 'opened'

with repository:
repository.post_status(pr.head, 'success', 'l/int')
env.run_crons()
assert pr_id.state == 'validated'

0 comments on commit abf1298

Please sign in to comment.