runbot/runbot_merge/models/project.py
Xavier Morel 9c51f87aed [ADD] runbot_merge: support for non-webhook staging validation
Add support for the ability to validate *stagings* over RPC rather
than via webhook. This may later be expanded to PRs as well.

The core motivation for this is to avoid bouncing through github which
sometimes drops the ball on statuses, and it's frustrating to have a
staging time out because GH fucked up.

Implemented via RPC, requiring both the staging itself (by id) and the
head commit being affected, as that is necessary to know what CIs are
required for that head and correctly report cross branch on the
various PRs.

Fix #881 (kinda)
2024-06-04 08:56:51 +02:00

231 lines
9.0 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)
staging_statuses = fields.Boolean(default=True)
staging_rpc = fields.Boolean(default=False)
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))