mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 21:35:45 +07:00

Thank god I have a bunch of tests because once again I forgot / missed
a bunch of edge cases in doing the conversion, which the tests
caught (sadly that means I almost certainly broke a few untested edge
cases).
Important notes:
Handling of parent links
------------------------
Unlike PRs, batches don't lose their parent info ever, the link is
permanent, which is convenient to trawl through a forward port
(currently implemented very inefficiently, maybe we'll optimise that
in the future).
However this means the batch having a parent and the batch's PRs
having parents are slightly different informations, one of the edge
cases I missed is that of conflicting PRs, which are deparented and
have to be merged by hand before being forward ported further, I had
originally replaced the checks on a pr and its sibling having parents
by just the batch.
Batches & targets
-----------------
Batches were originally concepted as being fixed to a target and PRs
having that target, a PR being retargeted would move it from one batch
to an other.
As it turns out this does not work in the case where people retarget
forward-port PRs, which I know they do because #551
(2337bd8518
). I could not think of a
good way to handle this issue as is, so scrapped the moving PRs thing,
instead one of the coherence checks of a batch being ready is that all
its PRs have the same target, and a batch only has a target if all its
PRs have the same target.
It's possible for somewhat odd effects to arise, notably if a PR is
closed (removed from batch), the other PRs are retargeted, and the new
PR is reopened, it will now be on a separate batch even if it also
gets retargeted. This is weird. I don't quite know how I should handle
it, maybe batches could merge if they have the same target and label?
however batches don't currently have a label so...
Improve limits
--------------
Keep limits on the PRs rather than lift them on the batchL if we can
add/remove PRs of batches having different limits on different PRs of
the same batch is reasonable.
Also leave limit unset by default: previously, the limit was eagerly
set to the tip (accessible) branch. That doesn't really seem
necessary, so stop doing that.
Also remove completely unnecessary `max` when trying to find a PR's
next target: `root` is either `self` or `self.source_id`, so it should
not be possible for that to have a later target.
And for now ensure the limits are consistent per batch: a PR defaults
to the limit of their batch-mate if they don't have one, and if a
limit is set via command it's set on all PRs of a batch.
This commit does not allow differential limits via commands, they are
allowed via the backend but not really tested. The issue is mostly
that it's not clear what the UX should look like to have clear and not
super error prone interactions. So punt on it for now, and hopefully
there's no hole I missed which will create inconsistent batches.
229 lines
8.9 KiB
Python
229 lines
8.9 KiB
Python
import logging
|
|
import re
|
|
from typing import List
|
|
|
|
import requests
|
|
import sentry_sdk
|
|
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
from odoo.osv import expression
|
|
from odoo.tools import reverse_order
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
class Project(models.Model):
|
|
_name = _description = 'runbot_merge.project'
|
|
|
|
name = fields.Char(required=True, index=True)
|
|
repo_ids = fields.One2many(
|
|
'runbot_merge.repository', 'project_id',
|
|
help="Repos included in that project, they'll be staged together. "\
|
|
"*Not* to be used for cross-repo dependencies (that is to be handled by the CI)"
|
|
)
|
|
branch_ids = fields.One2many(
|
|
'runbot_merge.branch', 'project_id',
|
|
context={'active_test': False},
|
|
help="Branches of all project's repos which are managed by the merge bot. Also "\
|
|
"target branches of PR this project handles."
|
|
)
|
|
staging_enabled = fields.Boolean(default=True)
|
|
staging_priority = fields.Selection([
|
|
('default', "Splits over ready PRs"),
|
|
('largest', "Largest of split and ready PRs"),
|
|
('ready', "Ready PRs over split"),
|
|
], default="default", required=True)
|
|
|
|
ci_timeout = fields.Integer(
|
|
default=60, required=True, group_operator=None,
|
|
help="Delay (in minutes) before a staging is considered timed out and failed"
|
|
)
|
|
|
|
github_token = fields.Char("Github Token", required=True)
|
|
github_name = fields.Char(store=True, compute="_compute_identity")
|
|
github_email = fields.Char(store=True, compute="_compute_identity")
|
|
github_prefix = fields.Char(
|
|
required=True,
|
|
default="hanson", # mergebot du bot du bot du~
|
|
help="Prefix (~bot name) used when sending commands from PR "
|
|
"comments e.g. [hanson retry] or [hanson r+ priority]",
|
|
)
|
|
fp_github_token = fields.Char()
|
|
fp_github_name = fields.Char(store=True, compute="_compute_git_identity")
|
|
|
|
batch_limit = fields.Integer(
|
|
default=8, group_operator=None, help="Maximum number of PRs staged together")
|
|
|
|
secret = fields.Char(
|
|
help="Webhook secret. If set, will be checked against the signature "
|
|
"of (valid) incoming webhook signatures, failing signatures "
|
|
"will lead to webhook rejection. Should only use ASCII."
|
|
)
|
|
|
|
freeze_id = fields.Many2one('runbot_merge.project.freeze', compute='_compute_freeze')
|
|
freeze_reminder = fields.Text()
|
|
|
|
uniquifier = fields.Boolean(
|
|
default=True,
|
|
help="Whether to add a uniquifier commit on repositories without PRs"
|
|
" during staging. The lack of uniquifier can lead to CI conflicts"
|
|
" as github works off of commits, so it's possible for an"
|
|
" unrelated build to trigger a failure if somebody is a dummy and"
|
|
" includes repos they have no commit for."
|
|
)
|
|
|
|
@api.depends('github_token')
|
|
def _compute_identity(self):
|
|
s = requests.Session()
|
|
for project in self:
|
|
if not project.github_token or (project.github_name and project.github_email):
|
|
continue
|
|
|
|
r0 = s.get('https://api.github.com/user', headers={
|
|
'Authorization': 'token %s' % project.github_token
|
|
})
|
|
if not r0.ok:
|
|
_logger.error("Failed to fetch merge bot information for project %s: %s", project.name, r0.text or r0.content)
|
|
continue
|
|
|
|
r = r0.json()
|
|
project.github_name = r['name'] or r['login']
|
|
if email := r['email']:
|
|
project.github_email = email
|
|
continue
|
|
|
|
if 'user:email' not in set(re.split(r',\s*', r0.headers['x-oauth-scopes'])):
|
|
raise UserError("The merge bot github token needs the user:email scope to fetch the bot's identity.")
|
|
r1 = s.get('https://api.github.com/user/emails', headers={
|
|
'Authorization': 'token %s' % project.github_token
|
|
})
|
|
if not r1.ok:
|
|
_logger.error("Failed to fetch merge bot emails for project %s: %s", project.name, r1.text or r1.content)
|
|
continue
|
|
project.github_email = next((
|
|
entry['email']
|
|
for entry in r1.json()
|
|
if entry['primary']
|
|
), None)
|
|
if not project.github_email:
|
|
raise UserError("The merge bot needs a public or accessible primary email set up.")
|
|
|
|
# technically the email could change at any moment...
|
|
@api.depends('fp_github_token')
|
|
def _compute_git_identity(self):
|
|
s = requests.Session()
|
|
for project in self:
|
|
if project.fp_github_name or not project.fp_github_token:
|
|
continue
|
|
|
|
r0 = s.get('https://api.github.com/user', headers={
|
|
'Authorization': 'token %s' % project.fp_github_token
|
|
})
|
|
if not r0.ok:
|
|
_logger.error("Failed to fetch forward bot information for project %s: %s", project.name, r0.text or r0.content)
|
|
continue
|
|
|
|
user = r0.json()
|
|
project.fp_github_name = user['name'] or user['login']
|
|
|
|
def _check_stagings(self, commit=False):
|
|
# check branches with an active staging
|
|
for branch in self.env['runbot_merge.branch']\
|
|
.with_context(active_test=False)\
|
|
.search([('active_staging_id', '!=', False)]):
|
|
staging = branch.active_staging_id
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
staging.check_status()
|
|
except Exception:
|
|
_logger.exception("Failed to check staging for branch %r (staging %s)",
|
|
branch.name, staging)
|
|
else:
|
|
if commit:
|
|
self.env.cr.commit()
|
|
|
|
def _create_stagings(self, commit=False):
|
|
from .stagings_create import try_staging
|
|
|
|
# look up branches which can be staged on and have no active staging
|
|
for branch in self.env['runbot_merge.branch'].search([
|
|
('active_staging_id', '=', False),
|
|
('active', '=', True),
|
|
('staging_enabled', '=', True),
|
|
('project_id.staging_enabled', '=', True),
|
|
]):
|
|
try:
|
|
with self.env.cr.savepoint(), \
|
|
sentry_sdk.start_span(description=f'create staging {branch.name}') as span:
|
|
span.set_tag('branch', branch.name)
|
|
try_staging(branch)
|
|
except Exception:
|
|
_logger.exception("Failed to create staging for branch %r", branch.name)
|
|
else:
|
|
if commit:
|
|
self.env.cr.commit()
|
|
|
|
def _find_commands(self, comment: str) -> List[str]:
|
|
"""Tries to find all the lines starting (ignoring leading whitespace)
|
|
with either the merge or the forward port bot identifiers.
|
|
|
|
For convenience, the identifier *can* be prefixed with an ``@`` or
|
|
``#``, and suffixed with a ``:``.
|
|
"""
|
|
# horizontal whitespace (\s - {\n, \r}), but Python doesn't have \h or \p{Blank}
|
|
h = r'[^\S\r\n]'
|
|
return re.findall(
|
|
fr'^{h}*[@|#]?{self.github_prefix}(?:{h}+|:{h}*)(.*)$',
|
|
comment, re.MULTILINE | re.IGNORECASE)
|
|
|
|
def _has_branch(self, name):
|
|
self.env.cr.execute("""
|
|
SELECT 1 FROM runbot_merge_branch
|
|
WHERE project_id = %s AND name = %s
|
|
LIMIT 1
|
|
""", (self.id, name))
|
|
return bool(self.env.cr.rowcount)
|
|
|
|
def _next_freeze(self):
|
|
prev = self.branch_ids[1:2].name
|
|
if not prev:
|
|
return None
|
|
|
|
m = re.search(r'(\d+)(?:\.(\d+))?$', prev)
|
|
if m:
|
|
return "%s.%d" % (m[1], (int(m[2] or 0) + 1))
|
|
else:
|
|
return f'post-{prev}'
|
|
|
|
def _compute_freeze(self):
|
|
freezes = {
|
|
f.project_id.id: f.id
|
|
for f in self.env['runbot_merge.project.freeze'].search([('project_id', 'in', self.ids)])
|
|
}
|
|
for project in self:
|
|
project.freeze_id = freezes.get(project.id) or False
|
|
|
|
def action_prepare_freeze(self):
|
|
""" Initialises the freeze wizard and returns the corresponding action.
|
|
"""
|
|
self.check_access_rights('write')
|
|
self.check_access_rule('write')
|
|
Freeze = self.env['runbot_merge.project.freeze'].sudo()
|
|
|
|
w = Freeze.search([('project_id', '=', self.id)]) or Freeze.create({
|
|
'project_id': self.id,
|
|
'branch_name': self._next_freeze(),
|
|
'release_pr_ids': [
|
|
(0, 0, {'repository_id': repo.id})
|
|
for repo in self.repo_ids
|
|
if repo.freeze
|
|
]
|
|
})
|
|
return w.action_open()
|
|
|
|
def _forward_port_ordered(self, domain=()):
|
|
Branches = self.env['runbot_merge.branch']
|
|
return Branches.search(expression.AND([
|
|
[('project_id', '=', self.id)],
|
|
domain or [],
|
|
]), order=reverse_order(Branches._order))
|