2020-07-10 15:21:43 +07:00
# coding: utf-8
2020-01-22 13:52:10 +07:00
import ast
2018-09-10 21:00:26 +07:00
import base64
2019-08-23 21:16:30 +07:00
import collections
2022-06-23 19:25:07 +07:00
import contextlib
2018-03-14 16:37:46 +07:00
import datetime
2019-08-23 21:16:30 +07:00
import io
2019-11-07 14:14:45 +07:00
import itertools
2018-03-14 16:37:46 +07:00
import json
import logging
2018-09-10 21:00:26 +07:00
import os
2018-03-14 16:37:46 +07:00
import pprint
import re
2018-09-20 21:42:35 +07:00
import time
2018-03-14 16:37:46 +07:00
from itertools import takewhile
2019-04-29 17:42:54 +07:00
import requests
2020-11-17 21:21:21 +07:00
import werkzeug
2019-08-23 21:16:30 +07:00
from werkzeug . datastructures import Headers
2019-04-29 17:42:54 +07:00
2018-03-14 16:37:46 +07:00
from odoo import api , fields , models , tools
from odoo . exceptions import ValidationError
2021-03-01 20:42:20 +07:00
from odoo . osv import expression
2019-08-23 21:16:30 +07:00
from odoo . tools import OrderedSet
2018-03-14 16:37:46 +07:00
2019-02-28 20:45:31 +07:00
from . . import github , exceptions , controllers , utils
2018-03-14 16:37:46 +07:00
2019-11-07 14:14:45 +07:00
WAIT_FOR_VISIBILITY = [ 10 , 10 , 10 , 10 ]
2018-09-21 20:57:16 +07:00
2018-03-14 16:37:46 +07:00
_logger = logging . getLogger ( __name__ )
2018-06-22 15:55:44 +07:00
2020-07-10 15:21:43 +07:00
class StatusConfiguration ( models . Model ) :
_name = ' runbot_merge.repository.status '
_description = " required statuses on repositories "
_rec_name = ' context '
_log_access = False
context = fields . Char ( required = True )
repo_id = fields . Many2one ( ' runbot_merge.repository ' , required = True , ondelete = ' cascade ' )
2020-10-02 15:48:25 +07:00
branch_filter = fields . Char ( help = " branches this status applies to " )
2020-07-10 17:55:39 +07:00
prs = fields . Boolean ( string = " Applies to pull requests " , default = True )
stagings = fields . Boolean ( string = " Applies to stagings " , default = True )
2020-07-10 15:21:43 +07:00
def _for_branch ( self , branch ) :
assert branch . _name == ' runbot_merge.branch ' , \
f ' Expected branch, got { branch } '
2020-10-02 15:48:25 +07:00
return self . filtered ( lambda st : (
not st . branch_filter
or branch . filtered_domain ( ast . literal_eval ( st . branch_filter ) )
) )
2020-07-10 17:55:39 +07:00
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 ' )
def _for_staging ( self , staging ) :
assert staging . _name == ' runbot_merge.stagings ' , \
f ' Expected staging, got { staging } '
return self . _for_branch ( staging . target ) . filtered ( ' stagings ' )
2018-03-14 16:37:46 +07:00
class Repository ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.repository '
2020-02-04 13:45:52 +07:00
_order = ' sequence, id '
2018-03-14 16:37:46 +07:00
2022-12-07 21:13:55 +07:00
sequence = fields . Integer ( default = 50 , group_operator = None )
2018-03-14 16:37:46 +07:00
name = fields . Char ( required = True )
project_id = fields . Many2one ( ' runbot_merge.project ' , required = True )
2020-07-10 15:21:43 +07:00
status_ids = fields . One2many ( ' runbot_merge.repository.status ' , ' repo_id ' , string = " Required Statuses " )
2020-11-13 16:38:48 +07:00
group_id = fields . Many2one ( ' res.groups ' , default = lambda self : self . env . ref ( ' base.group_user ' ) )
2020-01-22 13:52:10 +07:00
branch_filter = fields . Char ( default = ' [(1, " = " , 1)] ' , help = " Filter branches valid for this repository " )
2020-02-11 20:20:32 +07:00
substitutions = fields . Text (
" label substitutions " ,
help = """ sed-style substitution patterns applied to the label on input, one per line.
All substitutions are tentatively applied sequentially to the input .
""" )
2018-03-14 16:37:46 +07:00
2020-07-10 15:21:43 +07:00
@api.model
def create ( self , vals ) :
if ' status_ids ' in vals :
return super ( ) . create ( vals )
st = vals . pop ( ' required_statuses ' , ' legal/cla,ci/runbot ' )
if st :
vals [ ' status_ids ' ] = [ ( 0 , 0 , { ' context ' : c } ) for c in st . split ( ' , ' ) ]
return super ( ) . create ( vals )
def write ( self , vals ) :
st = vals . pop ( ' required_statuses ' , None )
if st :
vals [ ' status_ids ' ] = [ ( 5 , 0 , { } ) ] + [ ( 0 , 0 , { ' context ' : c } ) for c in st . split ( ' , ' ) ]
return super ( ) . write ( vals )
2019-09-18 20:37:14 +07:00
def github ( self , token_field = ' github_token ' ) :
return github . GH ( self . project_id [ token_field ] , self . name )
2018-03-14 16:37:46 +07:00
def _auto_init ( self ) :
res = super ( Repository , self ) . _auto_init ( )
tools . create_unique_index (
2018-03-26 16:04:43 +07:00
self . _cr , ' runbot_merge_unique_repo ' , self . _table , [ ' name ' ] )
2018-03-14 16:37:46 +07:00
return res
2018-06-21 14:55:14 +07:00
def _load_pr ( self , number ) :
gh = self . github ( )
# fetch PR object and handle as *opened*
issue , pr = gh . pr ( number )
2018-06-22 15:55:44 +07:00
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
feedback = self . env [ ' runbot_merge.pull_requests.feedback ' ] . create
2018-06-22 15:55:44 +07:00
if not self . project_id . _has_branch ( pr [ ' base ' ] [ ' ref ' ] ) :
2020-10-01 16:49:47 +07:00
_logger . info ( " Tasked with loading PR %d for un-managed branch %s : %s , ignoring " ,
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
number , self . name , pr [ ' base ' ] [ ' ref ' ] )
feedback ( {
2018-10-16 17:40:45 +07:00
' repository ' : self . id ,
' pull_request ' : number ,
2022-06-23 19:25:07 +07:00
' message ' : " Branch ` {} ` is not within my remit, imma just ignore it. " . format ( pr [ ' base ' ] [ ' ref ' ] ) ,
2018-10-16 17:40:45 +07:00
} )
2018-06-22 15:55:44 +07:00
return
2019-11-20 20:57:40 +07:00
# if the PR is already loaded, check... if the heads match?
pr_id = self . env [ ' runbot_merge.pull_requests ' ] . search ( [
( ' repository.name ' , ' = ' , pr [ ' base ' ] [ ' repo ' ] [ ' full_name ' ] ) ,
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
( ' number ' , ' = ' , number ) ,
2019-11-20 20:57:40 +07:00
] )
if pr_id :
# TODO: edited, maybe (requires crafting a 'changes' object)
r = controllers . handle_pr ( self . env , {
' action ' : ' synchronize ' ,
' pull_request ' : pr ,
2021-07-30 14:20:57 +07:00
' sender ' : { ' login ' : self . project_id . github_prefix }
2019-11-20 20:57:40 +07:00
} )
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
feedback ( {
2019-11-20 20:57:40 +07:00
' repository ' : pr_id . repository . id ,
2020-07-30 14:14:15 +07:00
' pull_request ' : number ,
2019-11-20 20:57:40 +07:00
' message ' : r ,
} )
return
2021-07-30 14:20:57 +07:00
feedback ( {
' repository ' : self . id ,
' pull_request ' : number ,
2022-06-23 19:25:07 +07:00
' message ' : " %s I didn ' t know about this PR and had to retrieve "
" its information, you may have to re-approve it as "
" I didn ' t see previous commands. " % pr_id . ping ( )
2021-07-30 14:20:57 +07:00
} )
2022-07-04 14:44:11 +07:00
sender = { ' login ' : self . project_id . github_prefix }
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
# init the PR to the null commit so we can later synchronise it back
# back to the "proper" head while resetting reviews
2018-06-21 14:55:14 +07:00
controllers . handle_pr ( self . env , {
' action ' : ' opened ' ,
2021-07-30 14:20:57 +07:00
' pull_request ' : {
* * pr ,
' head ' : { * * pr [ ' head ' ] , ' sha ' : ' 0 ' * 40 } ,
' state ' : ' open ' ,
} ,
2022-07-04 14:44:11 +07:00
' sender ' : sender ,
2018-06-21 14:55:14 +07:00
} )
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
# fetch & set up actual head
2018-06-21 14:55:14 +07:00
for st in gh . statuses ( pr [ ' head ' ] [ ' sha ' ] ) :
controllers . handle_status ( self . env , st )
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
# fetch and apply comments
counter = itertools . count ( )
items = [ # use counter so `comment` and `review` don't get hit during sort
( comment [ ' created_at ' ] , next ( counter ) , False , comment )
for comment in gh . comments ( number )
] + [
( review [ ' submitted_at ' ] , next ( counter ) , True , review )
for review in gh . reviews ( number )
]
items . sort ( )
for _ , _ , is_review , item in items :
if is_review :
controllers . handle_review ( self . env , {
' action ' : ' submitted ' ,
' review ' : item ,
' pull_request ' : pr ,
' repository ' : { ' full_name ' : self . name } ,
2022-07-04 14:44:11 +07:00
' sender ' : sender ,
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
} )
else :
controllers . handle_comment ( self . env , {
' action ' : ' created ' ,
' issue ' : issue ,
' comment ' : item ,
' repository ' : { ' full_name ' : self . name } ,
2022-07-04 14:44:11 +07:00
' sender ' : sender ,
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
} )
# sync to real head
controllers . handle_pr ( self . env , {
' action ' : ' synchronize ' ,
' pull_request ' : pr ,
2022-07-04 14:44:11 +07:00
' sender ' : sender ,
[FIX] runbot_merge: cancel approval (r+) when fetching PRs
When retrieving unknown PRs, the process would apply all comments,
thereby applying eventual r+ without taking in account their
relationship to a force push. This means it was possible for a
mergebot-unknown PR to be r+'d, updated, retargeted, and the mergetbot
would consider it good to go.
The possible damage would be somewhat limited but still, not great.
Sadly Github simply doesn't provide access to the entire event stream
of the PR, so there is no way to even know whether the PR was updated,
let alone when in relation to comments. Therefore just resync the PR
after having applied comments: we still want to apply the merge method
& al, we just want to reset back to un-approved.
An other minor fix (for something we never actually hit but could):
reviews are treated more or less as comments, but separate at github's
level. The job would apply all comments then all reviews, so the
relative order of comments and reviews would be wrong.
Combine and order comments and reviews so they are applied
in (hopefully) the correct order of their creation / submission.
Closes #416
2020-11-10 22:13:08 +07:00
} )
2021-07-30 14:20:57 +07:00
if pr [ ' state ' ] == ' closed ' :
# don't go through controller because try_closing does weird things
# for safety / race condition reasons which ends up committing
# and breaks everything
self . env [ ' runbot_merge.pull_requests ' ] . search ( [
( ' repository.name ' , ' = ' , pr [ ' base ' ] [ ' repo ' ] [ ' full_name ' ] ) ,
( ' number ' , ' = ' , number ) ,
] ) . state = ' closed '
2018-06-21 14:55:14 +07:00
2020-01-22 13:52:10 +07:00
def having_branch ( self , branch ) :
branches = self . env [ ' runbot_merge.branch ' ] . search
return self . filtered ( lambda r : branch in branches ( ast . literal_eval ( r . branch_filter ) ) )
2020-02-11 20:20:32 +07:00
def _remap_label ( self , label ) :
for line in filter ( None , ( self . substitutions or ' ' ) . splitlines ( ) ) :
sep = line [ 0 ]
_ , pattern , repl , flags = line . split ( sep )
label = re . sub (
pattern , repl , label ,
count = 0 if ' g ' in flags else 1 ,
flags = ( re . MULTILINE if ' m ' in flags . lower ( ) else 0 )
| ( re . IGNORECASE if ' i ' in flags . lower ( ) else 0 )
)
return label
2018-03-14 16:37:46 +07:00
class Branch ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.branch '
2019-06-28 16:31:06 +07:00
_order = ' sequence, name '
2018-03-14 16:37:46 +07:00
name = fields . Char ( required = True )
project_id = fields . Many2one ( ' runbot_merge.project ' , required = True )
2018-06-18 20:23:23 +07:00
active_staging_id = fields . Many2one (
' runbot_merge.stagings ' , compute = ' _compute_active_staging ' , store = True ,
help = " Currently running staging for the branch. "
2018-03-14 16:37:46 +07:00
)
staging_ids = fields . One2many ( ' runbot_merge.stagings ' , ' target ' )
2018-06-18 20:23:23 +07:00
split_ids = fields . One2many ( ' runbot_merge.split ' , ' target ' )
2018-03-14 16:37:46 +07:00
2018-06-18 15:08:48 +07:00
prs = fields . One2many ( ' runbot_merge.pull_requests ' , ' target ' , domain = [
( ' state ' , ' != ' , ' closed ' ) ,
( ' state ' , ' != ' , ' merged ' ) ,
] )
2019-06-28 16:31:06 +07:00
active = fields . Boolean ( default = True )
2022-12-07 21:13:55 +07:00
sequence = fields . Integer ( group_operator = None )
2019-06-28 16:31:06 +07:00
2018-03-14 16:37:46 +07:00
def _auto_init ( self ) :
res = super ( Branch , self ) . _auto_init ( )
tools . create_unique_index (
self . _cr , ' runbot_merge_unique_branch_per_repo ' ,
self . _table , [ ' name ' , ' project_id ' ] )
return res
2022-07-29 17:37:23 +07:00
@api.depends ( ' active ' )
def _compute_display_name ( self ) :
super ( ) . _compute_display_name ( )
for b in self . filtered ( lambda b : not b . active ) :
b . display_name + = ' (inactive) '
def write ( self , vals ) :
super ( ) . write ( vals )
if vals . get ( ' active ' ) is False :
2022-11-02 15:24:46 +07:00
self . active_staging_id . cancel (
" Target branch deactivated by %r . " ,
self . env . user . login ,
)
2022-07-29 17:37:23 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( [ {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
2022-11-10 21:55:21 +07:00
' message ' : f ' Hey { pr . ping ( ) } the target branch { pr . target . name !r} has been disabled, you may want to close this PR. ' ,
2022-07-29 17:37:23 +07:00
} for pr in self . prs ] )
return True
2018-06-18 20:23:23 +07:00
@api.depends ( ' staging_ids.active ' )
def _compute_active_staging ( self ) :
for b in self :
2019-08-27 17:28:53 +07:00
b . active_staging_id = b . with_context ( active_test = True ) . staging_ids
2018-06-18 20:23:23 +07:00
2020-01-31 21:19:32 +07:00
def _ready ( self ) :
2018-10-12 21:15:37 +07:00
self . env . cr . execute ( """
SELECT
min ( pr . priority ) as priority ,
array_agg ( pr . id ) AS match
FROM runbot_merge_pull_requests pr
2019-04-05 13:22:05 +07:00
WHERE pr . target = any ( % s )
2018-10-12 21:15:37 +07:00
- - exclude terminal states ( so there ' s no issue when
- - deleting branches & reusing labels )
AND pr . state != ' merged '
AND pr . state != ' closed '
2018-10-23 19:03:45 +07:00
GROUP BY
2020-02-11 15:49:39 +07:00
pr . target ,
2018-10-23 19:03:45 +07:00
CASE
WHEN pr . label SIMILAR TO ' %% :patch-[[:digit:]]+ '
THEN pr . id : : text
ELSE pr . label
END
2018-11-26 16:28:13 +07:00
HAVING
2020-01-31 21:19:32 +07:00
bool_or ( pr . state = ' ready ' ) or bool_or ( pr . priority = 0 )
2018-10-12 21:15:37 +07:00
ORDER BY min ( pr . priority ) , min ( pr . id )
2019-04-05 13:22:05 +07:00
""" , [self.ids])
2020-01-31 21:19:32 +07:00
browse = self . env [ ' runbot_merge.pull_requests ' ] . browse
return [ ( p , browse ( ids ) ) for p , ids in self . env . cr . fetchall ( ) ]
def _stageable ( self ) :
return [
( p , prs )
for p , prs in self . _ready ( )
if not any ( prs . mapped ( ' blocked ' ) )
]
2019-04-05 13:22:05 +07:00
def try_staging ( self ) :
""" Tries to create a staging if the current branch does not already
have one . Returns None if the branch already has a staging or there
is nothing to stage , the newly created staging otherwise .
"""
logger = _logger . getChild ( ' cron ' )
logger . info (
" Checking %s ( %s ) for staging: %s , skip? %s " ,
self , self . name ,
self . active_staging_id ,
bool ( self . active_staging_id )
)
if self . active_staging_id :
return
rows = self . _stageable ( )
2018-10-12 21:15:37 +07:00
priority = rows [ 0 ] [ 0 ] if rows else - 1
2019-07-31 14:19:28 +07:00
if priority == 0 or priority == 1 :
2018-10-12 21:15:37 +07:00
# p=0 take precedence over all else
2019-07-31 14:19:28 +07:00
# p=1 allows merging a fix inside / ahead of a split (e.g. branch
# is broken or widespread false positive) without having to cancel
# the existing staging
2020-01-31 21:19:32 +07:00
batched_prs = [ pr_ids for _ , pr_ids in takewhile ( lambda r : r [ 0 ] == priority , rows ) ]
2018-10-12 21:15:37 +07:00
elif self . split_ids :
split_ids = self . split_ids [ 0 ]
logger . info ( " Found split of PRs %s , re-staging " , split_ids . mapped ( ' batch_ids.prs ' ) )
batched_prs = [ batch . prs for batch in split_ids . batch_ids ]
split_ids . unlink ( )
2019-07-31 14:19:28 +07:00
else : # p=2
2020-01-31 21:19:32 +07:00
batched_prs = [ pr_ids for _ , pr_ids in takewhile ( lambda r : r [ 0 ] == priority , rows ) ]
2019-07-31 14:19:28 +07:00
if not batched_prs :
2018-10-12 21:15:37 +07:00
return
Batch = self . env [ ' runbot_merge.batch ' ]
staged = Batch
2021-08-09 18:21:24 +07:00
original_heads = { }
2020-01-22 13:52:10 +07:00
meta = { repo : { } for repo in self . project_id . repo_ids . having_branch ( self ) }
2018-10-12 21:15:37 +07:00
for repo , it in meta . items ( ) :
gh = it [ ' gh ' ] = repo . github ( )
2021-08-09 18:21:24 +07:00
it [ ' head ' ] = original_heads [ repo ] = gh . head ( self . name )
2018-10-12 21:15:37 +07:00
# create tmp staging branch
gh . set_ref ( ' tmp. {} ' . format ( self . name ) , it [ ' head ' ] )
batch_limit = self . project_id . batch_limit
2020-10-02 20:24:54 +07:00
first = True
2018-10-12 21:15:37 +07:00
for batch in batched_prs :
if len ( staged ) > = batch_limit :
break
2020-10-02 20:24:54 +07:00
try :
staged | = Batch . stage ( meta , batch )
except exceptions . MergeError as e :
2021-10-06 23:01:54 +07:00
pr = e . args [ 0 ]
_logger . exception ( " Failed to merge %s into staging branch " , pr . display_name )
2021-08-03 18:45:21 +07:00
if first or isinstance ( e , exceptions . Unmergeable ) :
if len ( e . args ) > 1 and e . args [ 1 ] :
2022-06-23 19:25:07 +07:00
reason = e . args [ 1 ]
2021-08-03 18:45:21 +07:00
else :
2022-06-23 19:25:07 +07:00
reason = e . __context__
# if the reason is a json document, assume it's a github
# error and try to extract the error message to give it to
# the user
with contextlib . suppress ( Exception ) :
reason = json . loads ( str ( reason ) ) [ ' message ' ] . lower ( )
2020-10-02 20:24:54 +07:00
pr . state = ' error '
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
2022-06-23 19:25:07 +07:00
' message ' : f ' { pr . ping ( ) } unable to stage: { reason } ' ,
2020-10-02 20:24:54 +07:00
} )
else :
first = False
2018-10-12 21:15:37 +07:00
if not staged :
return
heads = { }
for repo , it in meta . items ( ) :
tree = it [ ' gh ' ] . commit ( it [ ' head ' ] ) [ ' tree ' ]
# ensures staging branches are unique and always
# rebuilt
r = base64 . b64encode ( os . urandom ( 12 ) ) . decode ( ' ascii ' )
2019-08-09 19:31:21 +07:00
trailer = ' '
if heads :
2020-02-18 19:38:58 +07:00
trailer = ' \n ' . join (
2019-08-09 19:31:21 +07:00
' Runbot-dependency: %s : %s ' % ( repo , h )
for repo , h in heads . items ( )
if not repo . endswith ( ' ^ ' )
)
2021-08-09 18:21:24 +07:00
dummy_head = { ' sha ' : it [ ' head ' ] }
if it [ ' head ' ] == original_heads [ repo ] :
# if the repo has not been updated by the staging, create a
# dummy commit to force rebuild
dummy_head = it [ ' gh ' ] ( ' post ' , ' git/commits ' , json = {
' message ' : ''' force rebuild
2020-02-18 19:38:58 +07:00
uniquifier : % s
For - Commit - Id : % s
% s ''' % (r, it[ ' head ' ], trailer),
2021-08-09 18:21:24 +07:00
' tree ' : tree [ ' sha ' ] ,
' parents ' : [ it [ ' head ' ] ] ,
} ) . json ( )
2018-10-12 21:15:37 +07:00
2021-08-09 18:21:24 +07:00
# $repo is the head to check, $repo^ is the head to merge (they
# might be the same)
2018-10-12 21:15:37 +07:00
heads [ repo . name + ' ^ ' ] = it [ ' head ' ]
heads [ repo . name ] = dummy_head [ ' sha ' ]
2021-08-09 18:21:24 +07:00
self . env . cr . execute (
" INSERT INTO runbot_merge_commit (sha, to_check, statuses) "
" VALUES ( %s , true, ' {} ' ) "
" ON CONFLICT (sha) DO UPDATE SET to_check=true " ,
[ dummy_head [ ' sha ' ] ]
)
2018-10-12 21:15:37 +07:00
# create actual staging object
st = self . env [ ' runbot_merge.stagings ' ] . create ( {
' target ' : self . id ,
' batch_ids ' : [ ( 4 , batch . id , 0 ) for batch in staged ] ,
' heads ' : json . dumps ( heads )
} )
# create staging branch from tmp
2019-04-29 17:42:54 +07:00
token = self . project_id . github_token
2020-01-22 13:52:10 +07:00
for r in self . project_id . repo_ids . having_branch ( self ) :
2018-10-12 21:15:37 +07:00
it = meta [ r ]
2019-04-29 17:42:54 +07:00
staging_head = heads [ r . name ]
2018-10-12 21:15:37 +07:00
_logger . info (
" %s : create staging for %s : %s at %s " ,
self . project_id . name , r . name , self . name ,
2019-04-29 17:42:54 +07:00
staging_head
2018-10-12 21:15:37 +07:00
)
2019-04-29 17:42:54 +07:00
refname = ' staging. {} ' . format ( self . name )
it [ ' gh ' ] . set_ref ( refname , staging_head )
# asserts that the new head is visible through the api
head = it [ ' gh ' ] . head ( refname )
assert head == staging_head , \
" [api] updated %s : %s to %s but found %s " % (
r . name , refname ,
staging_head , head ,
)
2019-11-07 14:14:45 +07:00
i = itertools . count ( )
@utils.backoff ( delays = WAIT_FOR_VISIBILITY , exc = TimeoutError )
def wait_for_visibility ( ) :
2019-04-29 17:42:54 +07:00
if self . _check_visibility ( r , refname , staging_head , token ) :
_logger . info (
" [repo] updated %s : %s to %s : ok (at %d / %d ) " ,
r . name , refname , staging_head ,
2019-11-07 14:14:45 +07:00
next ( i ) , len ( WAIT_FOR_VISIBILITY )
2019-04-29 17:42:54 +07:00
)
2019-11-07 14:14:45 +07:00
return
2019-10-10 16:36:14 +07:00
_logger . warning (
2019-04-29 17:42:54 +07:00
" [repo] updated %s : %s to %s : failed (at %d / %d ) " ,
r . name , refname , staging_head ,
2019-11-07 14:14:45 +07:00
next ( i ) , len ( WAIT_FOR_VISIBILITY )
2019-04-29 17:42:54 +07:00
)
raise TimeoutError ( " Staged head not updated after %d seconds " % sum ( WAIT_FOR_VISIBILITY ) )
2019-08-23 21:16:30 +07:00
logger . info ( " Created staging %s ( %s ) to %s " , st , ' , ' . join (
' %s [ %s ] ' % ( batch , batch . prs )
for batch in staged
) , st . target . name )
2018-10-12 21:15:37 +07:00
return st
2019-04-29 17:42:54 +07:00
def _check_visibility ( self , repo , branch_name , expected_head , token ) :
""" Checks the repository actual to see if the new / expected head is
now visible
"""
# v1 protocol provides URL for ref discovery: https://github.com/git/git/blob/6e0cc6776106079ed4efa0cc9abace4107657abf/Documentation/technical/http-protocol.txt#L187
# for more complete client this is also the capabilities discovery and
# the "entry point" for the service
url = ' https://github.com/ {} .git/info/refs?service=git-upload-pack ' . format ( repo . name )
with requests . get ( url , stream = True , auth = ( token , ' ' ) ) as resp :
if not resp . ok :
return False
for head , ref in parse_refs_smart ( resp . raw . read ) :
if ref != ( ' refs/heads/ ' + branch_name ) :
continue
return head == expected_head
return False
2019-08-23 21:16:30 +07:00
ACL = collections . namedtuple ( ' ACL ' , ' is_admin is_reviewer is_author ' )
2018-03-14 16:37:46 +07:00
class PullRequests ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.pull_requests '
2018-03-14 16:37:46 +07:00
_order = ' number desc '
2021-11-12 21:57:16 +07:00
_rec_name = ' number '
2018-03-14 16:37:46 +07:00
2019-08-21 16:21:06 +07:00
target = fields . Many2one ( ' runbot_merge.branch ' , required = True , index = True )
2018-03-14 16:37:46 +07:00
repository = fields . Many2one ( ' runbot_merge.repository ' , required = True )
# NB: check that target & repo have same project & provide project related?
state = fields . Selection ( [
( ' opened ' , ' Opened ' ) ,
( ' closed ' , ' Closed ' ) ,
( ' validated ' , ' Validated ' ) ,
( ' approved ' , ' Approved ' ) ,
( ' ready ' , ' Ready ' ) ,
# staged?
( ' merged ' , ' Merged ' ) ,
( ' error ' , ' Error ' ) ,
2018-10-15 21:19:29 +07:00
] , default = ' opened ' , index = True )
2018-03-14 16:37:46 +07:00
2022-12-07 21:13:55 +07:00
number = fields . Integer ( required = True , index = True , group_operator = None )
2018-03-14 16:37:46 +07:00
author = fields . Many2one ( ' res.partner ' )
2018-06-21 14:55:14 +07:00
head = fields . Char ( required = True )
2018-03-14 16:37:46 +07:00
label = fields . Char (
required = True , index = True ,
help = " Label of the source branch (owner:branchname), used for "
" cross-repository branch-matching "
)
message = fields . Text ( required = True )
2021-08-11 16:36:35 +07:00
draft = fields . Boolean ( default = False , required = True )
2018-03-14 16:37:46 +07:00
squash = fields . Boolean ( default = False )
2018-11-26 16:28:13 +07:00
merge_method = fields . Selection ( [
( ' merge ' , " merge directly, using the PR as merge commit message " ) ,
( ' rebase-merge ' , " rebase and merge, using the PR as merge commit message " ) ,
( ' rebase-ff ' , " rebase and fast-forward " ) ,
2021-10-20 13:58:12 +07:00
( ' squash ' , " squash " ) ,
2018-11-26 16:28:13 +07:00
] , default = False )
method_warned = fields . Boolean ( default = False )
2018-03-14 16:37:46 +07:00
2018-11-22 00:43:05 +07:00
reviewed_by = fields . Many2one ( ' res.partner ' )
2018-06-18 15:08:48 +07:00
delegates = fields . Many2many ( ' res.partner ' , help = " Delegate reviewers, not intrinsically reviewers but can review this PR " )
2022-12-07 21:13:55 +07:00
priority = fields . Integer ( default = 2 , index = True , group_operator = None )
2018-03-14 16:37:46 +07:00
2020-07-14 15:06:07 +07:00
overrides = fields . Char ( required = True , default = ' {} ' )
2021-01-13 14:18:17 +07:00
statuses = fields . Text (
compute = ' _compute_statuses ' ,
help = " Copy of the statuses from the HEAD commit, as a Python literal "
)
statuses_full = fields . Text (
compute = ' _compute_statuses ' ,
help = " Compilation of the full status of the PR (commit statuses + overrides), as JSON "
)
2019-07-31 14:19:50 +07:00
status = fields . Char ( compute = ' _compute_statuses ' )
2019-10-07 21:38:14 +07:00
previous_failure = fields . Char ( default = ' {} ' )
2018-03-14 16:37:46 +07:00
2020-01-13 14:43:10 +07:00
batch_id = fields . Many2one ( ' runbot_merge.batch ' , string = " Active Batch " , compute = ' _compute_active_batch ' , store = True )
2021-08-03 19:14:44 +07:00
batch_ids = fields . Many2many ( ' runbot_merge.batch ' , string = " Batches " , context = { ' active_test ' : False } )
2018-03-14 16:37:46 +07:00
staging_id = fields . Many2one ( related = ' batch_id.staging_id ' , store = True )
2019-07-31 14:19:39 +07:00
commits_map = fields . Char ( help = " JSON-encoded mapping of PR commits to actually integrated commits. The integration head (either a merge commit or the PR ' s topmost) is mapped from the ' empty ' pr commit (the key is an empty string, because you can ' t put a null key in json maps). " , default = ' {} ' )
2018-03-14 16:37:46 +07:00
2018-10-15 21:19:29 +07:00
link_warned = fields . Boolean (
default = False , help = " Whether we ' ve already warned that this (ready) "
" PR is linked to an other non-ready PR "
)
2020-01-31 21:19:32 +07:00
blocked = fields . Char (
2019-04-05 13:22:05 +07:00
compute = ' _compute_is_blocked ' ,
help = " PR is not currently stageable for some reason (mostly an issue if status is ready) "
)
2020-11-17 21:21:21 +07:00
url = fields . Char ( compute = ' _compute_url ' )
github_url = fields . Char ( compute = ' _compute_url ' )
2022-02-08 17:06:34 +07:00
repo_name = fields . Char ( related = ' repository.name ' )
message_title = fields . Char ( compute = ' _compute_message_title ' )
2022-06-23 19:25:07 +07:00
def ping ( self , author = True , reviewer = True ) :
P = self . env [ ' res.partner ' ]
s = ' ' . join (
f ' @ { p . github_login } '
for p in ( self . author if author else P ) | ( self . reviewed_by if reviewer else P )
if p
)
if s :
s + = ' '
return s
2020-11-17 21:21:21 +07:00
@api.depends ( ' repository.name ' , ' number ' )
def _compute_url ( self ) :
base = werkzeug . urls . url_parse ( self . env [ ' ir.config_parameter ' ] . sudo ( ) . get_param ( ' web.base.url ' , ' http://localhost:8069 ' ) )
gh_base = werkzeug . urls . url_parse ( ' https://github.com ' )
for pr in self :
path = f ' / { werkzeug . urls . url_quote ( pr . repository . name ) } /pull/ { pr . number } '
pr . url = str ( base . join ( path ) )
pr . github_url = str ( gh_base . join ( path ) )
2022-02-08 17:06:34 +07:00
@api.depends ( ' message ' )
def _compute_message_title ( self ) :
for pr in self :
pr . message_title = next ( iter ( pr . message . splitlines ( ) ) , ' ' )
@api.depends ( ' repository.name ' , ' number ' , ' message ' )
2019-08-23 21:16:30 +07:00
def _compute_display_name ( self ) :
return super ( PullRequests , self ) . _compute_display_name ( )
def name_get ( self ) :
2022-02-08 17:06:34 +07:00
name_template = ' %(repo_name)s # %(number)d '
if self . env . context . get ( ' pr_include_title ' ) :
name_template + = ' ( %(message_title)s ) '
return [ ( p . id , name_template % p ) for p in self ]
2019-08-23 21:16:30 +07:00
2021-03-01 20:42:20 +07:00
@api.model
def name_search ( self , name = ' ' , args = None , operator = ' ilike ' , limit = 100 ) :
if not name or operator != ' ilike ' :
return super ( ) . name_search ( name , args = args , operator = operator , limit = limit )
bits = [ [ ( ' label ' , ' ilike ' , name ) ] ]
if name . isdigit ( ) :
bits . append ( [ ( ' number ' , ' = ' , name ) ] )
if re . match ( r ' \ w+# \ d+$ ' , name ) :
repo , num = name . rsplit ( ' # ' , 1 )
bits . append ( [ ' & ' , ( ' repository.name ' , ' ilike ' , repo ) , ( ' number ' , ' = ' , int ( num ) ) ] )
else :
bits . append ( [ ( ' repository.name ' , ' ilike ' , name ) ] )
domain = expression . OR ( bits )
if args :
domain = expression . AND ( [ args , domain ] )
return self . search ( domain , limit = limit ) . sudo ( ) . name_get ( )
2020-11-13 16:38:48 +07:00
@property
def _approved ( self ) :
return self . state in ( ' approved ' , ' ready ' ) or any (
p . priority == 0
for p in ( self | self . _linked_prs )
)
@property
def _ready ( self ) :
return ( self . squash or self . merge_method ) and self . _approved and self . status == ' success '
@property
def _linked_prs ( self ) :
if re . search ( r ' :patch- \ d+ ' , self . label ) :
return self . browse ( ( ) )
2021-07-23 20:45:23 +07:00
if self . state == ' merged ' :
return self . with_context ( active_test = False ) . batch_ids \
. filtered ( lambda b : b . staging_id . state == ' success ' ) \
. prs - self
2020-11-13 16:38:48 +07:00
return self . search ( [
( ' target ' , ' = ' , self . target . id ) ,
( ' label ' , ' = ' , self . label ) ,
( ' state ' , ' not in ' , ( ' merged ' , ' closed ' ) ) ,
] ) - self
2019-04-05 13:22:05 +07:00
# missing link to other PRs
@api.depends ( ' priority ' , ' state ' , ' squash ' , ' merge_method ' , ' batch_id.active ' , ' label ' )
def _compute_is_blocked ( self ) :
2020-01-31 21:19:32 +07:00
self . blocked = False
2019-04-05 13:22:05 +07:00
for pr in self :
2020-01-31 21:19:32 +07:00
if pr . state in ( ' merged ' , ' closed ' ) :
continue
2020-11-13 16:38:48 +07:00
linked = pr . _linked_prs
2020-01-31 21:19:32 +07:00
# check if PRs are configured (single commit or merge method set)
if not ( pr . squash or pr . merge_method ) :
2020-10-01 16:49:47 +07:00
pr . blocked = ' has no merge method '
2020-01-31 21:19:32 +07:00
continue
2020-11-13 16:38:48 +07:00
other_unset = next ( ( p for p in linked if not ( p . squash or p . merge_method ) ) , None )
2020-01-31 21:19:32 +07:00
if other_unset :
pr . blocked = " linked PR %s has no merge method " % other_unset . display_name
continue
# check if any PR in the batch is p=0 and none is in error
2020-11-13 16:38:48 +07:00
if any ( p . priority == 0 for p in ( pr | linked ) ) :
if pr . state == ' error ' :
pr . blocked = " in error "
other_error = next ( ( p for p in linked if p . state == ' error ' ) , None )
if other_error :
pr . blocked = " linked pr %s in error " % other_error . display_name
2020-01-31 21:19:32 +07:00
# if none is in error then none is blocked because p=0
# "unblocks" the entire batch
continue
if pr . state != ' ready ' :
pr . blocked = ' not ready '
continue
2020-11-13 16:38:48 +07:00
unready = next ( ( p for p in linked if p . state != ' ready ' ) , None )
2020-01-31 21:19:32 +07:00
if unready :
pr . blocked = ' linked pr %s is not ready ' % unready . display_name
continue
2019-03-04 18:11:34 +07:00
2021-01-13 14:18:17 +07:00
def _get_overrides ( self ) :
if self :
return json . loads ( self . overrides )
return { }
@api.depends ( ' head ' , ' repository.status_ids ' , ' overrides ' )
2018-03-14 16:37:46 +07:00
def _compute_statuses ( self ) :
Commits = self . env [ ' runbot_merge.commit ' ]
2020-07-10 15:21:43 +07:00
for pr in self :
c = Commits . search ( [ ( ' sha ' , ' = ' , pr . head ) ] )
2020-07-14 15:06:07 +07:00
st = json . loads ( c . statuses or ' {} ' )
2021-01-13 14:18:17 +07:00
statuses = { * * st , * * pr . _get_overrides ( ) }
pr . statuses_full = json . dumps ( statuses )
2020-07-14 15:06:07 +07:00
if not statuses :
2020-07-10 15:21:43 +07:00
pr . status = pr . statuses = False
2019-07-31 14:19:50 +07:00
continue
2020-07-14 15:06:07 +07:00
pr . statuses = pprint . pformat ( st )
2019-07-31 14:19:50 +07:00
st = ' success '
2020-07-10 17:55:39 +07:00
for ci in pr . repository . status_ids . _for_pr ( pr ) :
2020-07-10 15:21:43 +07:00
v = state_ ( statuses , ci . context ) or ' pending '
2019-07-31 14:19:50 +07:00
if v in ( ' error ' , ' failure ' ) :
st = ' failure '
break
if v == ' pending ' :
st = ' pending '
2020-07-10 15:21:43 +07:00
pr . status = st
2018-03-14 16:37:46 +07:00
2018-06-18 17:59:57 +07:00
@api.depends ( ' batch_ids.active ' )
def _compute_active_batch ( self ) :
for r in self :
r . batch_id = r . batch_ids . filtered ( lambda b : b . active ) [ : 1 ]
2018-06-22 15:55:44 +07:00
def _get_or_schedule ( self , repo_name , number , target = None ) :
repo = self . env [ ' runbot_merge.repository ' ] . search ( [ ( ' name ' , ' = ' , repo_name ) ] )
if not repo :
return
if target and not repo . project_id . _has_branch ( target ) :
2018-10-16 17:40:45 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : repo . id ,
' pull_request ' : number ,
' message ' : " I ' m sorry. Branch ` {} ` is not within my remit. " . format ( target ) ,
} )
2018-06-22 15:55:44 +07:00
return
2018-06-21 14:55:14 +07:00
pr = self . search ( [
2018-06-22 15:55:44 +07:00
( ' repository ' , ' = ' , repo . id ) ,
2018-06-21 14:55:14 +07:00
( ' number ' , ' = ' , number , )
] )
if pr :
return pr
Fetch = self . env [ ' runbot_merge.fetch_job ' ]
2018-06-22 15:55:44 +07:00
if Fetch . search ( [ ( ' repository ' , ' = ' , repo . id ) , ( ' number ' , ' = ' , number ) ] ) :
2018-06-21 14:55:14 +07:00
return
2018-06-22 15:55:44 +07:00
Fetch . create ( {
' repository ' : repo . id ,
' number ' : number ,
} )
2018-06-21 14:55:14 +07:00
2018-03-14 16:37:46 +07:00
def _parse_command ( self , commandstring ) :
2018-11-26 16:28:13 +07:00
for m in re . finditer (
2020-07-14 15:06:07 +07:00
r ' ( \ S+?)(?:([+-])|=( \ S*))?(?= \ s|$) ' ,
2018-11-26 16:28:13 +07:00
commandstring ,
) :
name , flag , param = m . groups ( )
2020-07-14 15:06:07 +07:00
if name == ' r ' :
name = ' review '
if flag in ( ' + ' , ' - ' ) :
yield name , flag == ' + '
2018-11-26 16:28:13 +07:00
elif name == ' delegate ' :
2020-07-14 15:06:07 +07:00
if param :
2021-01-12 15:25:53 +07:00
for p in param . split ( ' , ' ) :
yield ' delegate ' , p . lstrip ( ' #@ ' )
elif name == ' override ' :
if param :
for p in param . split ( ' , ' ) :
yield ' override ' , p
2018-11-26 16:28:13 +07:00
elif name in ( ' p ' , ' priority ' ) :
if param in ( ' 0 ' , ' 1 ' , ' 2 ' ) :
yield ( ' priority ' , int ( param ) )
elif any ( name == k for k , _ in type ( self ) . merge_method . selection ) :
yield ( ' method ' , name )
2020-07-14 15:06:07 +07:00
else :
yield name , param
2018-03-14 16:37:46 +07:00
2018-10-16 17:40:45 +07:00
def _parse_commands ( self , author , comment , login ) :
2018-03-14 16:37:46 +07:00
""" Parses a command string prefixed by Project::github_prefix.
A command string can contain any number of space - separated commands :
retry
resets a PR in error mode to ready for staging
r ( eview ) + / -
approves or disapproves a PR ( disapproving just cancels an approval )
delegate + / delegate = < users >
adds either PR author or the specified ( github ) users as
authorised reviewers for this PR . ` ` < users > ` ` is a
comma - separated list of github usernames ( no @ )
p ( riority ) = 2 | 1 | 0
sets the priority to normal ( 2 ) , pressing ( 1 ) or urgent ( 0 ) .
Lower - priority PRs are selected first and batched together .
2018-08-29 21:51:53 +07:00
rebase + / -
Whether the PR should be rebased - and - merged ( the default ) or just
merged normally .
2018-03-14 16:37:46 +07:00
"""
2018-06-21 14:55:14 +07:00
assert self , " parsing commands must be executed in an actual PR "
2018-10-16 17:40:45 +07:00
( login , name ) = ( author . github_login , author . display_name ) if author else ( login , ' not in system ' )
2019-08-23 21:16:30 +07:00
is_admin , is_reviewer , is_author = self . _pr_acl ( author )
2018-03-14 16:37:46 +07:00
2021-01-12 15:25:53 +07:00
commands = [
2018-03-14 16:37:46 +07:00
ps
2020-07-14 15:06:07 +07:00
for m in self . repository . project_id . _find_commands ( comment [ ' body ' ] or ' ' )
2018-11-26 16:28:13 +07:00
for ps in self . _parse_command ( m )
2021-01-12 15:25:53 +07:00
]
2018-03-14 16:37:46 +07:00
2018-06-07 22:30:06 +07:00
if not commands :
2019-02-28 20:45:31 +07:00
_logger . info ( " found no commands in comment of %s ( %s ) ( %s ) " , author . github_login , author . display_name ,
2020-07-14 15:06:07 +07:00
utils . shorten ( comment [ ' body ' ] or ' ' , 50 )
2018-06-07 22:30:06 +07:00
)
return ' ok '
2018-10-16 17:40:45 +07:00
Feedback = self . env [ ' runbot_merge.pull_requests.feedback ' ]
2021-01-12 15:25:53 +07:00
if not ( is_author or any ( cmd == ' override ' for cmd , _ in commands ) ) :
2018-10-16 17:40:45 +07:00
# no point even parsing commands
2020-01-28 21:34:29 +07:00
_logger . info ( " ignoring comment of %s ( %s ): no ACL to %s " ,
login , name , self . display_name )
2018-10-16 17:40:45 +07:00
Feedback . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
' message ' : " I ' m sorry, @ {} . I ' m afraid I can ' t do that. " . format ( login )
} )
return ' ignored '
2018-03-14 16:37:46 +07:00
applied , ignored = [ ] , [ ]
2018-10-16 17:40:45 +07:00
def reformat ( command , param ) :
if param is None :
pstr = ' '
elif isinstance ( param , bool ) :
pstr = ' + ' if param else ' - '
elif isinstance ( param , list ) :
pstr = ' = ' + ' , ' . join ( param )
else :
pstr = ' = {} ' . format ( param )
return ' %s %s ' % ( command , pstr )
msgs = [ ]
2021-01-12 15:25:53 +07:00
for command , param in commands :
2018-03-14 16:37:46 +07:00
ok = False
2022-06-23 19:25:07 +07:00
msg = None
2018-03-14 16:37:46 +07:00
if command == ' retry ' :
2018-10-16 17:40:45 +07:00
if is_author :
if self . state == ' error ' :
ok = True
self . state = ' ready '
else :
2022-06-23 19:25:07 +07:00
msg = " retry makes no sense when the PR is not in error. "
2019-11-20 20:57:40 +07:00
elif command == ' check ' :
if is_author :
self . env [ ' runbot_merge.fetch_job ' ] . create ( {
' repository ' : self . repository . id ,
' number ' : self . number ,
} )
2022-03-25 17:16:28 +07:00
ok = True
2018-03-14 16:37:46 +07:00
elif command == ' review ' :
2021-08-11 16:36:35 +07:00
if self . draft :
2022-06-23 19:25:07 +07:00
msg = " draft PRs can not be approved. "
2021-08-11 16:36:35 +07:00
elif param and is_reviewer :
2020-03-05 19:31:23 +07:00
oldstate = self . state
2018-09-25 20:04:31 +07:00
newstate = RPLUS . get ( self . state )
2021-10-06 18:06:53 +07:00
if not author . email :
msg = " I must know your email before you can review PRs. Please contact an administrator. "
elif not newstate :
2022-06-23 19:25:07 +07:00
msg = " this PR is already reviewed, reviewing it again is useless. "
2021-10-06 18:06:53 +07:00
else :
2018-09-25 20:04:31 +07:00
self . state = newstate
2018-11-22 00:43:05 +07:00
self . reviewed_by = author
2018-03-14 16:37:46 +07:00
ok = True
2020-03-05 19:31:23 +07:00
_logger . debug (
" r+ on %s by %s ( %s -> %s ) status= %s message? %s " ,
self . display_name , author . github_login ,
oldstate , newstate or oldstate ,
self . status , self . status == ' failure '
)
if self . status == ' failure ' :
# the normal infrastructure is for failure and
# prefixes messages with "I'm sorry"
Feedback . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
2022-06-23 19:25:07 +07:00
' message ' : " @ {} you may want to rebuild or fix this PR as it has failed CI. " . format ( login ) ,
2020-03-05 19:31:23 +07:00
} )
2018-09-25 20:04:31 +07:00
elif not param and is_author :
newstate = RMINUS . get ( self . state )
2020-02-10 17:50:40 +07:00
if self . priority == 0 or newstate :
if newstate :
self . state = newstate
if self . priority == 0 :
self . priority = 1
Feedback . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
' message ' : " PR priority reset to 1, as pull requests with priority 0 ignore review state. " ,
} )
2022-06-23 19:25:07 +07:00
self . unstage ( " unreviewed (r-) by %s " , login )
2018-03-14 16:37:46 +07:00
ok = True
2018-10-16 17:40:45 +07:00
else :
msg = " r- makes no sense in the current PR state. "
2018-03-14 16:37:46 +07:00
elif command == ' delegate ' :
if is_reviewer :
ok = True
2021-01-12 15:25:53 +07:00
Partners = self . env [ ' res.partner ' ]
2018-03-14 16:37:46 +07:00
if param is True :
2021-01-12 15:25:53 +07:00
delegate = self . author
2018-03-14 16:37:46 +07:00
else :
2021-01-12 15:25:53 +07:00
delegate = Partners . search ( [ ( ' github_login ' , ' = ' , param ) ] ) or Partners . create ( {
' name ' : param ,
' github_login ' : param ,
} )
delegate . write ( { ' delegate_reviewer ' : [ ( 4 , self . id , 0 ) ] } )
2018-03-14 16:37:46 +07:00
elif command == ' priority ' :
if is_admin :
ok = True
self . priority = param
2018-05-11 19:14:00 +07:00
if param == 0 :
self . target . active_staging_id . cancel (
2020-01-28 21:34:29 +07:00
" P=0 on %s by %s , unstaging target %s " ,
self . display_name ,
2018-05-11 19:14:00 +07:00
author . github_login , self . target . name ,
)
2018-11-26 16:28:13 +07:00
elif command == ' method ' :
2021-10-05 20:05:17 +07:00
if is_reviewer :
2022-11-04 15:16:58 +07:00
self . merge_method = param
ok = True
explanation = next ( label for value , label in type ( self ) . merge_method . selection if value == param )
Feedback . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
' message ' : " Merge method set to %s . " % explanation
} )
2020-07-14 15:06:07 +07:00
elif command == ' override ' :
overridable = author . override_rights \
2020-11-12 19:17:37 +07:00
. filtered ( lambda r : not r . repository_id or ( r . repository_id == self . repository ) ) \
2020-07-14 15:06:07 +07:00
. mapped ( ' context ' )
if param in overridable :
self . overrides = json . dumps ( {
* * json . loads ( self . overrides ) ,
param : {
' state ' : ' success ' ,
' target_url ' : comment [ ' html_url ' ] ,
' description ' : f " Overridden by @ { author . github_login } " ,
} ,
} )
c = self . env [ ' runbot_merge.commit ' ] . search ( [ ( ' sha ' , ' = ' , self . head ) ] )
if c :
c . to_check = True
else :
c . create ( { ' sha ' : self . head , ' statuses ' : ' {} ' } )
ok = True
else :
2022-06-23 19:25:07 +07:00
msg = " you are not allowed to override this status. "
2020-07-22 16:56:33 +07:00
else :
# ignore unknown commands
continue
2018-03-27 18:33:04 +07:00
2018-03-14 16:37:46 +07:00
_logger . info (
2020-01-28 21:34:29 +07:00
" %s %s ( %s ) on %s by %s ( %s ) " ,
2018-03-14 16:37:46 +07:00
" applied " if ok else " ignored " ,
2020-01-28 21:34:29 +07:00
command , param , self . display_name ,
2018-03-14 16:37:46 +07:00
author . github_login , author . display_name ,
)
if ok :
2018-10-16 17:40:45 +07:00
applied . append ( reformat ( command , param ) )
2018-03-14 16:37:46 +07:00
else :
2018-10-16 17:40:45 +07:00
ignored . append ( reformat ( command , param ) )
2022-06-23 19:25:07 +07:00
msgs . append ( msg or " you can ' t {} . " . format ( reformat ( command , param ) ) )
2018-10-16 17:40:45 +07:00
if msgs :
2022-06-23 19:25:07 +07:00
joiner = ' ' if len ( msgs ) == 1 else ' \n - '
msgs . insert ( 0 , " I ' m sorry, @ {} : " . format ( login ) )
2018-10-16 17:40:45 +07:00
Feedback . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
2022-06-23 19:25:07 +07:00
' message ' : joiner . join ( msgs ) ,
2018-10-16 17:40:45 +07:00
} )
2022-06-23 19:25:07 +07:00
msg = [ ]
if applied :
msg . append ( ' applied ' + ' ' . join ( applied ) )
if ignored :
ignoredstr = ' ' . join ( ignored )
msg . append ( ' ignored ' + ignoredstr )
2018-03-14 16:37:46 +07:00
return ' \n ' . join ( msg )
2019-08-23 21:16:30 +07:00
def _pr_acl ( self , user ) :
if not self :
return ACL ( False , False , False )
2020-02-10 21:05:08 +07:00
is_admin = self . env [ ' res.partner.review ' ] . search_count ( [
( ' partner_id ' , ' = ' , user . id ) ,
( ' repository_id ' , ' = ' , self . repository . id ) ,
( ' review ' , ' = ' , True ) if self . author != user else ( ' self_review ' , ' = ' , True ) ,
] ) == 1
2019-08-23 21:16:30 +07:00
is_reviewer = is_admin or self in user . delegate_reviewer
# TODO: should delegate reviewers be able to retry PRs?
is_author = is_reviewer or self . author == user
return ACL ( is_admin , is_reviewer , is_author )
2018-03-14 16:37:46 +07:00
def _validate ( self , statuses ) :
# could have two PRs (e.g. one open and one closed) at least
# temporarily on the same head, or on the same head with different
# targets
2019-09-18 13:32:38 +07:00
failed = self . browse ( ( ) )
2018-03-14 16:37:46 +07:00
for pr in self :
2020-07-10 17:55:39 +07:00
required = pr . repository . status_ids . _for_pr ( pr ) . mapped ( ' context ' )
2021-01-13 14:18:17 +07:00
sts = { * * statuses , * * pr . _get_overrides ( ) }
2019-03-05 15:03:26 +07:00
2019-07-31 14:19:50 +07:00
success = True
2019-03-05 15:03:26 +07:00
for ci in required :
2020-07-14 15:06:07 +07:00
st = state_ ( sts , ci ) or ' pending '
2019-03-05 15:03:26 +07:00
if st == ' success ' :
continue
2019-07-31 14:19:50 +07:00
success = False
2019-09-18 13:32:38 +07:00
if st in ( ' error ' , ' failure ' ) :
failed | = pr
2020-07-14 15:06:07 +07:00
pr . _notify_ci_new_failure ( ci , to_status ( sts . get ( ci . strip ( ) , ' pending ' ) ) )
2019-07-31 14:19:50 +07:00
if success :
2018-03-14 16:37:46 +07:00
oldstate = pr . state
if oldstate == ' opened ' :
pr . state = ' validated '
elif oldstate == ' approved ' :
pr . state = ' ready '
2019-09-18 13:32:38 +07:00
return failed
2018-03-14 16:37:46 +07:00
2019-10-07 21:38:14 +07:00
def _notify_ci_new_failure ( self , ci , st ) :
prev = json . loads ( self . previous_failure )
[FIX] runbot_merge: avoid repeatedly warning about the same failures
The mergebot has a feature to ping users when an approved PR or
forward-port suffers from a CI failure, as those PRs might be somewhat
unattended (so the author needs to be warned explicitly).
Because the runbot can send the same failure information multiple
times, the mergebot also has a *deduplication* feature, however this
deduplication feature was too weak to handle the case where the PR has
2+ failures e.g. ci and linting as it only stores the last-seen
failure, and there would be two different failures here.
Worse, because the validation step looks at all required statuses, in
that case it would send a failure ping message for each failed
status *on each inbound status*: first it'd notify about the ci
failure and store that, then it'd see the linting failure, check
against the previous (ci), consider it a new failure, notify, and
store that. Rinse and repeat every time runbot sends a ci *or* lint
failure, leading to a lot of dumb and useless spam.
Fix by storing the entire current failure state (a map of context:
status) instead of just the last-seen status data.
Note: includes a backwards-compatibility shim where we just convert a
stored status into a full `{context: status}` map. This uses the
"current context" because we don't have the original, but if it was a
different context it's not going to match anyway (the target_url
should be different) and if it was the same context then there's a
chance we skip sending a redundant notification.
Fixes #435
2021-01-13 18:32:24 +07:00
if prev . get ( ' state ' ) : # old-style previous-failure
prev = { ci : prev }
if not any ( self . _statuses_equivalent ( st , v ) for v in prev . values ( ) ) :
prev [ ci ] = st
self . previous_failure = json . dumps ( prev )
2019-10-07 21:38:14 +07:00
self . _notify_ci_failed ( ci )
2021-11-10 19:13:34 +07:00
def _notify_merged ( self , gh , payload ) :
deployment = gh ( ' POST ' , ' deployments ' , json = {
' ref ' : self . head , ' environment ' : ' merge ' ,
' description ' : " Merge %s into %s " % ( self . display_name , self . target . name ) ,
' task ' : ' merge ' ,
' auto_merge ' : False ,
' required_contexts ' : [ ] ,
} ) . json ( )
gh ( ' POST ' , ' deployments/ {} /statuses ' . format ( deployment [ ' id ' ] ) , json = {
' state ' : ' success ' ,
' target_url ' : ' https://github.com/ {} /commit/ {} ' . format (
self . repository . name ,
payload [ ' sha ' ] ,
) ,
' description ' : " Merged %s in %s at %s " % (
self . display_name , self . target . name , payload [ ' sha ' ]
)
} )
2020-01-31 16:36:01 +07:00
def _statuses_equivalent ( self , a , b ) :
""" Check if two statuses are *equivalent* meaning the description field
is ignored ( check only state and target_url ) . This is because the
description seems to vary even if the rest does not , and generates
unnecessary notififcations as a result
"""
return a . get ( ' state ' ) == b . get ( ' state ' ) \
and a . get ( ' target_url ' ) == b . get ( ' target_url ' )
2019-10-07 21:38:14 +07:00
def _notify_ci_failed ( self , ci ) :
# only report an issue of the PR is already approved (r+'d)
if self . state == ' approved ' :
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : self . repository . id ,
' pull_request ' : self . number ,
2022-06-23 19:25:07 +07:00
' message ' : " %s %r failed on this reviewed PR. " % ( self . ping ( ) , ci ) ,
2019-10-07 21:38:14 +07:00
} )
2018-03-14 16:37:46 +07:00
def _auto_init ( self ) :
2019-08-21 16:21:06 +07:00
super ( PullRequests , self ) . _auto_init ( )
# incorrect index: unique(number, target, repository).
tools . drop_index ( self . _cr , ' runbot_merge_unique_pr_per_target ' , self . _table )
# correct index:
2018-03-14 16:37:46 +07:00
tools . create_unique_index (
2019-08-21 16:21:06 +07:00
self . _cr , ' runbot_merge_unique_pr_per_repo ' , self . _table , [ ' repository ' , ' number ' ] )
2018-06-21 14:55:14 +07:00
self . _cr . execute ( " CREATE INDEX IF NOT EXISTS runbot_merge_pr_head "
" ON runbot_merge_pull_requests "
" USING hash (head) " )
2018-03-14 16:37:46 +07:00
2018-03-28 21:43:48 +07:00
@property
def _tagstate ( self ) :
if self . state == ' ready ' and self . staging_id . heads :
return ' staged '
return self . state
@api.model
def create ( self , vals ) :
pr = super ( ) . create ( vals )
2018-06-21 14:55:14 +07:00
c = self . env [ ' runbot_merge.commit ' ] . search ( [ ( ' sha ' , ' = ' , pr . head ) ] )
2019-10-03 21:04:30 +07:00
pr . _validate ( json . loads ( c . statuses or ' {} ' ) )
2018-06-21 14:55:14 +07:00
2018-04-03 20:02:59 +07:00
if pr . state not in ( ' closed ' , ' merged ' ) :
2020-11-17 21:21:21 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
2018-04-03 20:02:59 +07:00
' repository ' : pr . repository . id ,
2020-11-17 21:21:21 +07:00
' pull_request ' : pr . number ,
' message ' : f " [Pull request status dashboard]( { pr . url } ). " ,
2018-04-03 20:02:59 +07:00
} )
2018-03-28 21:43:48 +07:00
return pr
2019-08-23 21:16:30 +07:00
def _from_gh ( self , description , author = None , branch = None , repo = None ) :
if repo is None :
repo = self . env [ ' runbot_merge.repository ' ] . search ( [
( ' name ' , ' = ' , description [ ' base ' ] [ ' repo ' ] [ ' full_name ' ] ) ,
] )
if branch is None :
2020-10-06 17:43:57 +07:00
branch = self . env [ ' runbot_merge.branch ' ] . with_context ( active_test = False ) . search ( [
2019-08-23 21:16:30 +07:00
( ' name ' , ' = ' , description [ ' base ' ] [ ' ref ' ] ) ,
( ' project_id ' , ' = ' , repo . project_id . id ) ,
] )
if author is None :
author = self . env [ ' res.partner ' ] . search ( [
( ' github_login ' , ' = ' , description [ ' user ' ] [ ' login ' ] ) ,
] , limit = 1 )
message = description [ ' title ' ] . strip ( )
body = description [ ' body ' ] and description [ ' body ' ] . strip ( )
if body :
message + = ' \n \n ' + body
return self . env [ ' runbot_merge.pull_requests ' ] . create ( {
2021-07-30 14:20:57 +07:00
' state ' : ' opened ' if description [ ' state ' ] == ' open ' else ' closed ' ,
2019-08-23 21:16:30 +07:00
' number ' : description [ ' number ' ] ,
2020-02-11 20:20:32 +07:00
' label ' : repo . _remap_label ( description [ ' head ' ] [ ' label ' ] ) ,
2019-08-23 21:16:30 +07:00
' author ' : author . id ,
' target ' : branch . id ,
' repository ' : repo . id ,
' head ' : description [ ' head ' ] [ ' sha ' ] ,
' squash ' : description [ ' commits ' ] == 1 ,
' message ' : message ,
2021-08-11 16:36:35 +07:00
' draft ' : description [ ' draft ' ] ,
2019-08-23 21:16:30 +07:00
} )
2018-03-28 21:43:48 +07:00
def write ( self , vals ) :
2020-05-28 18:27:34 +07:00
if vals . get ( ' squash ' ) :
vals [ ' merge_method ' ] = False
2022-06-09 13:55:34 +07:00
prev = None
if ' target ' in vals or ' message ' in vals :
prev = {
pr . id : { ' target ' : pr . target , ' message ' : pr . message }
for pr in self
}
2020-05-28 18:27:34 +07:00
2018-03-28 21:43:48 +07:00
w = super ( ) . write ( vals )
2019-03-04 16:34:40 +07:00
newhead = vals . get ( ' head ' )
if newhead :
c = self . env [ ' runbot_merge.commit ' ] . search ( [ ( ' sha ' , ' = ' , newhead ) ] )
2020-02-07 22:11:12 +07:00
self . _validate ( json . loads ( c . statuses or ' {} ' ) )
2022-06-09 13:55:34 +07:00
if prev :
for pr in self :
old_target = prev [ pr . id ] [ ' target ' ]
if pr . target != old_target :
pr . unstage (
" target (base) branch was changed from %r to %r " ,
old_target . display_name , pr . target . display_name ,
)
old_message = prev [ pr . id ] [ ' message ' ]
if pr . merge_method in ( ' merge ' , ' rebase-merge ' ) and pr . message != old_message :
pr . unstage ( " merge message updated " )
2018-03-28 21:43:48 +07:00
return w
2018-10-15 21:19:29 +07:00
def _check_linked_prs_statuses ( self , commit = False ) :
""" Looks for linked PRs where at least one of the PRs is in a ready
state and the others are not , notifies the other PRs .
: param bool commit : whether to commit the tnx after each comment
"""
# similar to Branch.try_staging's query as it's a subset of that
# other query's behaviour
self . env . cr . execute ( """
SELECT
array_agg ( pr . id ) AS match
FROM runbot_merge_pull_requests pr
WHERE
- - exclude terminal states ( so there ' s no issue when
- - deleting branches & reusing labels )
pr . state != ' merged '
AND pr . state != ' closed '
2018-10-23 19:03:45 +07:00
GROUP BY
2021-07-29 20:15:17 +07:00
pr . target ,
2018-10-23 19:03:45 +07:00
CASE
WHEN pr . label SIMILAR TO ' %% :patch-[[:digit:]]+ '
THEN pr . id : : text
ELSE pr . label
END
2018-10-15 21:19:29 +07:00
HAVING
- - one of the batch ' s PRs should be ready & not marked
bool_or ( pr . state = ' ready ' AND NOT pr . link_warned )
- - one of the others should be unready
AND bool_or ( pr . state != ' ready ' )
2021-07-29 20:15:17 +07:00
- - but ignore batches with one of the prs at p0
2018-10-15 21:19:29 +07:00
AND bool_and ( pr . priority != 0 )
""" )
for [ ids ] in self . env . cr . fetchall ( ) :
prs = self . browse ( ids )
ready = prs . filtered ( lambda p : p . state == ' ready ' )
2019-01-25 21:45:12 +07:00
unready = ( prs - ready ) . sorted ( key = lambda p : ( p . repository . name , p . number ) )
2018-10-15 21:19:29 +07:00
for r in ready :
2018-10-29 15:42:26 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : r . repository . id ,
' pull_request ' : r . number ,
2022-06-23 19:25:07 +07:00
' message ' : " {} linked pull request(s) {} not ready. Linked PRs are not staged until all of them are ready. " . format (
r . ping ( ) ,
' , ' . join ( map ( ' {0.display_name} ' . format , unready ) )
2018-10-15 21:19:29 +07:00
)
2018-10-29 15:42:26 +07:00
} )
2018-10-15 21:19:29 +07:00
r . link_warned = True
if commit :
self . env . cr . commit ( )
2018-11-26 16:28:13 +07:00
# send feedback for multi-commit PRs without a merge_method (which
# we've not warned yet)
2022-06-23 19:25:07 +07:00
methods = ' ' . join (
' * ` %s ` to %s \n ' % pair
for pair in type ( self ) . merge_method . selection
if pair [ 0 ] != ' squash '
)
2018-11-26 16:28:13 +07:00
for r in self . search ( [
( ' state ' , ' = ' , ' ready ' ) ,
( ' squash ' , ' = ' , False ) ,
( ' merge_method ' , ' = ' , False ) ,
( ' method_warned ' , ' = ' , False ) ,
] ) :
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : r . repository . id ,
' pull_request ' : r . number ,
2022-06-23 19:25:07 +07:00
' message ' : " %s because this PR has multiple commits, I need to know how to merge it: \n \n %s " % (
r . ping ( ) ,
methods ,
2018-11-26 16:28:13 +07:00
)
} )
r . method_warned = True
if commit :
self . env . cr . commit ( )
2019-08-23 21:16:30 +07:00
def _parse_commit_message ( self , message ) :
""" Parses a commit message to split out the pseudo-headers (which
should be at the end ) from the body , and serialises back with a
predefined pseudo - headers ordering .
"""
return Message . from_message ( message )
2021-08-09 12:55:38 +07:00
def _is_mentioned ( self , message , * , full_reference = False ) :
""" Returns whether ``self`` is mentioned in ``message```
: param str | PullRequest message :
: param bool full_reference : whether the repository name must be present
: rtype : bool
"""
if full_reference :
pattern = fr ' \ b { re . escape ( self . display_name ) } \ b '
else :
repository = self . repository . name # .replace('/', '\\/')
pattern = fr ' ( | \ b { repository } )# { self . number } \ b '
return bool ( re . search ( pattern , message if isinstance ( message , str ) else message . message ) )
2019-11-22 15:21:40 +07:00
def _build_merge_message ( self , message , related_prs = ( ) ) :
2018-11-22 00:43:05 +07:00
# handle co-authored commits (https://help.github.com/articles/creating-a-commit-with-multiple-authors/)
2019-08-23 21:16:30 +07:00
m = self . _parse_commit_message ( message )
2021-08-09 12:55:38 +07:00
if not self . _is_mentioned ( message ) :
2019-10-10 17:07:57 +07:00
m . body + = ' \n \n closes {pr.display_name} ' . format ( pr = self )
2019-08-23 21:16:30 +07:00
2019-11-22 15:21:40 +07:00
for r in related_prs :
2021-08-09 12:55:38 +07:00
if not r . _is_mentioned ( message , full_reference = True ) :
2020-03-02 16:26:40 +07:00
m . headers . add ( ' Related ' , r . display_name )
2019-11-22 15:21:40 +07:00
2018-11-22 00:43:05 +07:00
if self . reviewed_by :
2019-08-23 21:16:30 +07:00
m . headers . add ( ' signed-off-by ' , self . reviewed_by . formatted_email )
2018-11-22 00:43:05 +07:00
2020-03-02 14:54:58 +07:00
return m
2018-11-26 16:28:13 +07:00
2021-08-09 12:55:38 +07:00
def _add_self_references ( self , commits ) :
""" Adds a footer reference to ``self`` to all ``commits`` if they don ' t
already refer to the PR .
"""
for c in ( c [ ' commit ' ] for c in commits ) :
if not self . _is_mentioned ( c [ ' message ' ] ) :
m = self . _parse_commit_message ( c [ ' message ' ] )
m . headers . pop ( ' Part-Of ' , None )
m . headers . add ( ' Part-Of ' , self . display_name )
c [ ' message ' ] = str ( m )
2019-11-22 15:21:40 +07:00
def _stage ( self , gh , target , related_prs = ( ) ) :
2018-11-26 16:28:13 +07:00
# nb: pr_commits is oldest to newest so pr.head is pr_commits[-1]
_ , prdict = gh . pr ( self . number )
commits = prdict [ ' commits ' ]
method = self . merge_method or ( ' rebase-ff ' if commits == 1 else None )
2021-08-03 18:45:21 +07:00
if commits > 50 and method . startswith ( ' rebase ' ) :
raise exceptions . Unmergeable ( self , " Rebasing 50 commits is too much. " )
if commits > 250 :
raise exceptions . Unmergeable (
self , " Merging PRs of 250 or more commits is not supported "
" (https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request) "
)
2018-11-26 16:28:13 +07:00
pr_commits = gh . commits ( self . number )
2021-08-03 18:45:21 +07:00
for c in pr_commits :
if not ( c [ ' commit ' ] [ ' author ' ] [ ' email ' ] and c [ ' commit ' ] [ ' committer ' ] [ ' email ' ] ) :
raise exceptions . Unmergeable (
self ,
f " All commits must have author and committer email, "
f " missing email on { c [ ' sha ' ] } indicates the authorship is "
f " most likely incorrect. "
)
2019-11-20 20:57:40 +07:00
pr_head = pr_commits [ - 1 ] [ ' sha ' ]
if pr_head != self . head :
2021-08-03 18:45:21 +07:00
raise exceptions . Mismatch ( self . head , pr_head , commits == 1 )
2018-11-26 16:28:13 +07:00
2018-11-22 00:43:05 +07:00
if self . reviewed_by and self . reviewed_by . name == self . reviewed_by . github_login :
# XXX: find other trigger(s) to sync github name?
gh_name = gh . user ( self . reviewed_by . github_login ) [ ' name ' ]
if gh_name :
self . reviewed_by . name = gh_name
[FIX] runbot_merge: ensure PR description is correct on merge
Because sometimes github updates are missed (usually because github
never triggers it), it's possible for the mergebot's view of a PR
description to be incorrect. In that case, the PR may get merged with
the wrong merge message entirely, through no fault of the user.
Since we already fetch the PR info when staging it, there's very
little overhead to checking that the PR message we store is correct
then, and update it if it's not. This means the forward-port's
description should also be correct.
While at it, clean the forward port PR's creation a bit:
- there should always be a message since the title is required on
PRs (only the body can be missing), therefore no need to check that
- as we're adding a bunch of pseudo-headers, there always is a body,
no need for the condition
- inline the `pr_data` and `URL`: they were extracted for the support
of draft PRs, since that's been removed it's now unnecessary
Fixes #530
2021-09-24 13:03:24 +07:00
# update pr message in case an update was missed
msg = f ' { prdict [ " title " ] } \n \n { prdict . get ( " body " ) or " " } ' . strip ( )
if self . message != msg :
self . message = msg
2018-11-26 16:28:13 +07:00
# NOTE: lost merge v merge/copy distinction (head being
# a merge commit reused instead of being re-merged)
2019-11-22 15:21:40 +07:00
return method , getattr ( self , ' _stage_ ' + method . replace ( ' - ' , ' _ ' ) ) (
gh , target , pr_commits , related_prs = related_prs )
2018-11-26 16:28:13 +07:00
2021-10-20 13:58:12 +07:00
def _stage_squash ( self , gh , target , commits , related_prs = ( ) ) :
msg = self . _build_merge_message ( self , related_prs = related_prs )
2022-12-07 19:25:08 +07:00
authorship = { }
2022-11-04 15:16:58 +07:00
authors = {
( c [ ' commit ' ] [ ' author ' ] [ ' name ' ] , c [ ' commit ' ] [ ' author ' ] [ ' email ' ] )
for c in commits
}
if len ( authors ) == 1 :
name , email = authors . pop ( )
2022-12-07 19:25:08 +07:00
authorship [ ' author ' ] = { ' name ' : name , ' email ' : email }
else :
msg . headers . extend ( sorted (
( ' Co-Authored-By ' , " %s < %s > " % author )
for author in authors
) )
2022-11-04 15:16:58 +07:00
committers = {
( c [ ' commit ' ] [ ' committer ' ] [ ' name ' ] , c [ ' commit ' ] [ ' committer ' ] [ ' email ' ] )
for c in commits
}
if len ( committers ) == 1 :
name , email = committers . pop ( )
2022-12-07 19:25:08 +07:00
authorship [ ' committer ' ] = { ' name ' : name , ' email ' : email }
2022-11-04 15:16:58 +07:00
# should committers also be added to co-authors?
original_head = gh . head ( target )
merge_tree = gh . merge ( self . head , target , ' temp merge ' ) [ ' tree ' ] [ ' sha ' ]
head = gh ( ' post ' , ' git/commits ' , json = {
2022-12-07 19:25:08 +07:00
* * authorship ,
2022-11-04 15:16:58 +07:00
' message ' : str ( msg ) ,
' tree ' : merge_tree ,
' parents ' : [ original_head ] ,
} ) . json ( ) [ ' sha ' ]
gh . set_ref ( target , head )
commits_map = { c [ ' sha ' ] : head for c in commits }
commits_map [ ' ' ] = head
self . commits_map = json . dumps ( commits_map )
2022-02-07 18:00:31 +07:00
return head
2021-10-20 13:58:12 +07:00
2019-11-22 15:21:40 +07:00
def _stage_rebase_ff ( self , gh , target , commits , related_prs = ( ) ) :
2018-11-26 16:28:13 +07:00
# updates head commit with PR number (if necessary) then rebases
# on top of target
2019-11-22 15:21:40 +07:00
msg = self . _build_merge_message ( commits [ - 1 ] [ ' commit ' ] [ ' message ' ] , related_prs = related_prs )
2020-03-02 14:54:58 +07:00
commits [ - 1 ] [ ' commit ' ] [ ' message ' ] = str ( msg )
2021-08-09 12:55:38 +07:00
self . _add_self_references ( commits [ : - 1 ] )
2019-07-31 14:19:39 +07:00
head , mapping = gh . rebase ( self . number , target , commits = commits )
self . commits_map = json . dumps ( { * * mapping , ' ' : head } )
return head
2018-11-26 16:28:13 +07:00
2019-11-22 15:21:40 +07:00
def _stage_rebase_merge ( self , gh , target , commits , related_prs = ( ) ) :
2021-08-09 12:55:38 +07:00
self . _add_self_references ( commits )
2019-07-31 14:19:39 +07:00
h , mapping = gh . rebase ( self . number , target , reset = True , commits = commits )
2021-08-09 12:55:38 +07:00
msg = self . _build_merge_message ( self , related_prs = related_prs )
2020-03-02 14:54:58 +07:00
merge_head = gh . merge ( h , target , str ( msg ) ) [ ' sha ' ]
2019-07-31 14:19:39 +07:00
self . commits_map = json . dumps ( { * * mapping , ' ' : merge_head } )
return merge_head
2018-11-26 16:28:13 +07:00
2019-11-22 15:21:40 +07:00
def _stage_merge ( self , gh , target , commits , related_prs = ( ) ) :
2018-11-26 16:28:13 +07:00
pr_head = commits [ - 1 ] # oldest to newest
base_commit = None
head_parents = { p [ ' sha ' ] for p in pr_head [ ' parents ' ] }
if len ( head_parents ) > 1 :
# look for parent(s?) of pr_head not in PR, means it's
# from target (so we merged target in pr)
merge = head_parents - { c [ ' sha ' ] for c in commits }
2021-08-03 18:45:21 +07:00
external_parents = len ( merge )
if external_parents > 1 :
raise exceptions . Unmergeable (
" The PR head can only have one parent from the base branch "
" (not part of the PR itself), found %d : %s " % (
external_parents ,
' , ' . join ( merge )
) )
if external_parents == 1 :
2018-11-26 16:28:13 +07:00
[ base_commit ] = merge
2019-07-31 14:19:39 +07:00
commits_map = { c [ ' sha ' ] : c [ ' sha ' ] for c in commits }
2018-11-26 16:28:13 +07:00
if base_commit :
# replicate pr_head with base_commit replaced by
# the current head
original_head = gh . head ( target )
merge_tree = gh . merge ( pr_head [ ' sha ' ] , target , ' temp merge ' ) [ ' tree ' ] [ ' sha ' ]
new_parents = [ original_head ] + list ( head_parents - { base_commit } )
2019-11-22 15:21:40 +07:00
msg = self . _build_merge_message ( pr_head [ ' commit ' ] [ ' message ' ] , related_prs = related_prs )
2018-11-26 16:28:13 +07:00
copy = gh ( ' post ' , ' git/commits ' , json = {
2020-03-02 14:54:58 +07:00
' message ' : str ( msg ) ,
2018-11-26 16:28:13 +07:00
' tree ' : merge_tree ,
' author ' : pr_head [ ' commit ' ] [ ' author ' ] ,
' committer ' : pr_head [ ' commit ' ] [ ' committer ' ] ,
' parents ' : new_parents ,
} ) . json ( )
gh . set_ref ( target , copy [ ' sha ' ] )
2019-07-31 14:19:39 +07:00
# merge commit *and old PR head* map to the pr head replica
commits_map [ ' ' ] = commits_map [ pr_head [ ' sha ' ] ] = copy [ ' sha ' ]
self . commits_map = json . dumps ( commits_map )
2018-11-26 16:28:13 +07:00
return copy [ ' sha ' ]
else :
# otherwise do a regular merge
2021-01-21 19:15:32 +07:00
msg = self . _build_merge_message ( self )
2020-03-02 14:54:58 +07:00
merge_head = gh . merge ( self . head , target , str ( msg ) ) [ ' sha ' ]
2019-07-31 14:19:39 +07:00
# and the merge commit is the normal merge head
commits_map [ ' ' ] = merge_head
self . commits_map = json . dumps ( commits_map )
return merge_head
2018-11-26 16:28:13 +07:00
2019-07-31 14:20:02 +07:00
def unstage ( self , reason , * args ) :
""" If the PR is staged, cancel the staging. If the PR is split and
waiting , remove it from the split ( possibly delete the split entirely )
"""
split_batches = self . with_context ( active_test = False ) . mapped ( ' batch_ids ' ) . filtered ( ' split_id ' )
if len ( split_batches ) > 1 :
2019-10-10 16:36:14 +07:00
_logger . warning ( " Found a PR linked with more than one split batch: %s ( %s ) " , self , split_batches )
2019-07-31 14:20:02 +07:00
for b in split_batches :
if len ( b . split_id . batch_ids ) == 1 :
# only the batch of this PR -> delete split
b . split_id . unlink ( )
else :
# else remove this batch from the split
b . split_id = False
2022-06-07 20:49:52 +07:00
self . staging_id . cancel ( ' %s ' + reason , self . display_name , * args )
2019-07-31 14:20:02 +07:00
2019-08-23 21:16:30 +07:00
def _try_closing ( self , by ) :
# ignore if the PR is already being updated in a separate transaction
# (most likely being merged?)
self . env . cr . execute ( '''
SELECT id , state FROM runbot_merge_pull_requests
WHERE id = % s AND state != ' merged '
FOR UPDATE SKIP LOCKED ;
''' , [self.id])
2021-11-16 20:01:23 +07:00
if not self . env . cr . fetchone ( ) :
2019-08-23 21:16:30 +07:00
return False
self . env . cr . execute ( '''
UPDATE runbot_merge_pull_requests
SET state = ' closed '
2021-11-16 20:01:23 +07:00
WHERE id = % s
2019-08-23 21:16:30 +07:00
''' , [self.id])
self . env . cr . commit ( )
2020-01-13 14:47:58 +07:00
self . modified ( [ ' state ' ] )
2022-06-07 20:49:52 +07:00
self . unstage ( " closed by %s " , by )
2019-08-23 21:16:30 +07:00
return True
2018-09-25 20:04:31 +07:00
# state changes on reviews
RPLUS = {
' opened ' : ' approved ' ,
' validated ' : ' ready ' ,
}
RMINUS = {
' approved ' : ' opened ' ,
' ready ' : ' validated ' ,
' error ' : ' validated ' ,
}
2018-03-28 21:43:48 +07:00
_TAGS = {
False : set ( ) ,
' opened ' : { ' seen 🙂 ' } ,
}
_TAGS [ ' validated ' ] = _TAGS [ ' opened ' ] | { ' CI 🤖 ' }
_TAGS [ ' approved ' ] = _TAGS [ ' opened ' ] | { ' r+ 👌 ' }
_TAGS [ ' ready ' ] = _TAGS [ ' validated ' ] | _TAGS [ ' approved ' ]
_TAGS [ ' staged ' ] = _TAGS [ ' ready ' ] | { ' merging 👷 ' }
_TAGS [ ' merged ' ] = _TAGS [ ' ready ' ] | { ' merged 🎉 ' }
_TAGS [ ' error ' ] = _TAGS [ ' opened ' ] | { ' error 🙅 ' }
_TAGS [ ' closed ' ] = _TAGS [ ' opened ' ] | { ' closed 💔 ' }
2020-03-10 19:36:46 +07:00
ALL_TAGS = set . union ( * _TAGS . values ( ) )
2018-03-28 21:43:48 +07:00
class Tagging ( models . Model ) :
"""
Queue of tag changes to make on PRs .
Several PR state changes are driven by webhooks , webhooks should return
quickly , performing calls to the Github API would * probably * get in the
way of that . Instead , queue tagging changes into this table whose
execution can be cron - driven .
"""
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.pull_requests.tagging '
2018-03-28 21:43:48 +07:00
2018-06-07 19:49:04 +07:00
repository = fields . Many2one ( ' runbot_merge.repository ' , required = True )
2018-03-28 21:43:48 +07:00
# store the PR number (not id) as we need a Tagging for PR objects
# being deleted (retargeted to non-managed branches)
2022-12-07 21:13:55 +07:00
pull_request = fields . Integer ( group_operator = None )
2018-03-28 21:43:48 +07:00
2020-03-10 19:36:46 +07:00
tags_remove = fields . Char ( required = True , default = ' [] ' )
2020-03-17 13:42:55 +07:00
tags_add = fields . Char ( required = True , default = ' [] ' )
2020-03-10 19:36:46 +07:00
def create ( self , values ) :
2020-03-12 14:33:15 +07:00
if values . pop ( ' state_from ' , None ) :
2020-03-10 19:36:46 +07:00
values [ ' tags_remove ' ] = ALL_TAGS
2020-03-12 14:33:15 +07:00
if ' state_to ' in values :
values [ ' tags_add ' ] = _TAGS [ values . pop ( ' state_to ' ) ]
2020-03-10 19:36:46 +07:00
if not isinstance ( values . get ( ' tags_remove ' , ' ' ) , str ) :
values [ ' tags_remove ' ] = json . dumps ( list ( values [ ' tags_remove ' ] ) )
if not isinstance ( values . get ( ' tags_add ' , ' ' ) , str ) :
values [ ' tags_add ' ] = json . dumps ( list ( values [ ' tags_add ' ] ) )
return super ( ) . create ( values )
2018-03-28 21:43:48 +07:00
2021-11-10 19:13:34 +07:00
def _send ( self ) :
# noinspection SqlResolve
self . env . cr . execute ( """
SELECT
t . repository as repo_id ,
t . pull_request as pr_number ,
array_agg ( t . id ) as ids ,
array_agg ( t . tags_remove : : json ) as to_remove ,
array_agg ( t . tags_add : : json ) as to_add
FROM runbot_merge_pull_requests_tagging t
GROUP BY t . repository , t . pull_request
""" )
Repos = self . env [ ' runbot_merge.repository ' ]
ghs = { }
to_remove = [ ]
for repo_id , pr , ids , remove , add in self . env . cr . fetchall ( ) :
repo = Repos . browse ( repo_id )
gh = ghs . get ( repo )
if not gh :
gh = ghs [ repo ] = repo . github ( )
# fold all grouped PRs'
tags_remove , tags_add = set ( ) , set ( )
for minus , plus in zip ( remove , add ) :
tags_remove . update ( minus )
# need to remove minuses from to_add in case we get e.g.
# -foo +bar; -bar +baz, if we don't remove the minus, we'll end
# up with -foo +bar +baz instead of -foo +baz
tags_add . difference_update ( minus )
tags_add . update ( plus )
try :
gh . change_tags ( pr , tags_remove , tags_add )
except Exception :
_logger . exception (
" Error while trying to change the tags of %s # %s from %s to %s " ,
repo . name , pr , remove , add ,
)
else :
to_remove . extend ( ids )
self . browse ( to_remove ) . unlink ( )
2018-10-16 17:40:45 +07:00
class Feedback ( models . Model ) :
""" Queue of feedback comments to send to PR users
"""
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.pull_requests.feedback '
2018-10-16 17:40:45 +07:00
repository = fields . Many2one ( ' runbot_merge.repository ' , required = True )
# store the PR number (not id) as we may want to send feedback to PR
# objects on non-handled branches
2022-12-07 21:13:55 +07:00
pull_request = fields . Integer ( group_operator = None )
2018-10-16 17:40:45 +07:00
message = fields . Char ( )
[IMP] runbot_merge: concurrency issue with GH closing PRs being merged
Once more unto the breach, with the issue of pushing stagings (with
"closes" annotations) to the target branch making GH close the PR &
send the hook, which makes runbot_merge consider the PR closed and the
staging cancelled.
This probably still doesn't fix the issue, but it reduces the
problematic window: before this, the process first updates the
branches, then marks the PRs, then comments & closes the PRs, and
finally commits the PR update.
This means as runbot_merge is sending a comment & a status update to
each PR in a staging, GH has some time to send the "closed" webhook
behind its back, making the controller immediately cancel the current
staging, especially if the v3 endpoint is a bit slow.
By moving the commenting & closing out of the critical path (to the
feedback queue), this window should be significantly shortened.
2018-10-24 21:14:31 +07:00
close = fields . Boolean ( )
2019-09-18 20:37:14 +07:00
token_field = fields . Selection (
[ ( ' github_token ' , " Mergebot " ) ] ,
default = ' github_token ' ,
string = " Bot User " ,
help = " Token field (from repo ' s project) to use to post messages "
)
2018-10-16 17:40:45 +07:00
2021-11-10 19:13:34 +07:00
def _send ( self ) :
ghs = { }
to_remove = [ ]
for f in self . search ( [ ] ) :
repo = f . repository
gh = ghs . get ( ( repo , f . token_field ) )
if not gh :
gh = ghs [ ( repo , f . token_field ) ] = repo . github ( f . token_field )
try :
message = f . message
2022-07-11 13:17:04 +07:00
with contextlib . suppress ( json . JSONDecodeError ) :
data = json . loads ( message or ' ' )
message = data . get ( ' message ' )
if data . get ( ' base ' ) :
gh ( ' PATCH ' , f ' pulls/ { f . pull_request } ' , json = { ' base ' : data [ ' base ' ] } )
if f . close :
2021-11-10 19:13:34 +07:00
pr_to_notify = self . env [ ' runbot_merge.pull_requests ' ] . search ( [
( ' repository ' , ' = ' , repo . id ) ,
( ' number ' , ' = ' , f . pull_request ) ,
] )
if pr_to_notify :
pr_to_notify . _notify_merged ( gh , data )
2022-07-11 13:17:04 +07:00
if f . close :
gh . close ( f . pull_request )
2021-11-10 19:13:34 +07:00
if message :
gh . comment ( f . pull_request , message )
except Exception :
_logger . exception (
" Error while trying to %s %s # %s ( %s ) " ,
' close ' if f . close else ' send a comment to ' ,
repo . name , f . pull_request ,
utils . shorten ( f . message , 200 )
)
else :
to_remove . append ( f . id )
self . browse ( to_remove ) . unlink ( )
2018-03-14 16:37:46 +07:00
class Commit ( models . Model ) :
""" Represents a commit onto which statuses might be posted,
independent of everything else as commits can be created by
statuses only , by PR pushes , by branch updates , . . .
"""
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.commit '
2018-03-14 16:37:46 +07:00
sha = fields . Char ( required = True )
statuses = fields . Char ( help = " json-encoded mapping of status contexts to states " , default = " {} " )
2019-03-05 14:01:38 +07:00
to_check = fields . Boolean ( default = False )
2018-03-14 16:37:46 +07:00
def create ( self , values ) :
2019-03-05 14:01:38 +07:00
values [ ' to_check ' ] = True
2018-03-14 16:37:46 +07:00
r = super ( Commit , self ) . create ( values )
return r
def write ( self , values ) :
2019-03-05 14:01:38 +07:00
values . setdefault ( ' to_check ' , True )
2018-03-14 16:37:46 +07:00
r = super ( Commit , self ) . write ( values )
return r
def _notify ( self ) :
Stagings = self . env [ ' runbot_merge.stagings ' ]
PRs = self . env [ ' runbot_merge.pull_requests ' ]
# chances are low that we'll have more than one commit
2019-03-05 14:01:38 +07:00
for c in self . search ( [ ( ' to_check ' , ' = ' , True ) ] ) :
2019-10-01 12:54:24 +07:00
try :
2019-10-01 14:57:35 +07:00
c . to_check = False
st = json . loads ( c . statuses )
pr = PRs . search ( [ ( ' head ' , ' = ' , c . sha ) ] )
if pr :
pr . _validate ( st )
[IMP] runbot_merge: precisely filter stagings before validating them
Before this, we would "roughly" select stagings by looking at stagings
whose heads matched a specific sha then validating them all. This
could perform extra validations on stagings once in a while but this
was assumed not to be much an issue, at least originally.
However two changes later on have contributed to this likely being the
cause of #429 (stagings never timing out):
* heads of the staging branches are uniquifier commits stored in the
heads map, but the actual heads of the stagings are also stored
there, some of which are no-ops (hence the uniquifiers) so assuming
repos A and B, if a staging contains PRs touching A then the head of
B actual will also be a head of B
* when a staging is validated, if it *contains* any pending result the
timeout limit gets bumped back
The issue here is that if a success / failure status is lost (which
would be the most common reason for timeouts) *and* someone has forked
and is regularly rebuilding a branch-head used as-is by a staging,
each of those rebuilds will trigger a validation of the staging, which
will find that one of the statuses is still pending (because we missed
the success / failure), which will bump up the timeout limit,
continuing until the branch stops getting rebuilt.
This is probably one of the reasons why some stagings last for *way*
more than 2h, though it is far from explaining all of them: 90% of the
stagings lasting more than *3*h end up succeeding. Tho it's always
possible that this is because someone notices and resends a success
for the missing status it seems somewhat doubtful. Oh well.
Also fix the incorrect log call on `update_timeout_limit` triggering.
2021-01-20 15:22:11 +07:00
stagings = Stagings . search ( [ ( ' heads ' , ' ilike ' , c . sha ) ] ) . filtered (
lambda s , h = c . sha : any (
head == h
for repo , head in json . loads ( s . heads ) . items ( )
if not repo . endswith ( ' ^ ' )
)
)
2019-10-01 14:57:35 +07:00
if stagings :
stagings . _validate ( )
2019-10-01 12:54:24 +07:00
except Exception :
_logger . exception ( " Failed to apply commit %s ( %s ) " , c , c . sha )
2019-10-01 14:57:35 +07:00
self . env . cr . rollback ( )
else :
self . env . cr . commit ( )
2019-03-05 14:01:38 +07:00
2018-06-21 14:55:14 +07:00
_sql_constraints = [
( ' unique_sha ' , ' unique (sha) ' , ' no duplicated commit ' ) ,
]
2018-03-14 16:37:46 +07:00
def _auto_init ( self ) :
res = super ( Commit , self ) . _auto_init ( )
2018-06-21 14:55:14 +07:00
self . _cr . execute ( """
2020-07-10 15:21:43 +07:00
CREATE INDEX IF NOT EXISTS runbot_merge_unique_statuses
2018-06-21 14:55:14 +07:00
ON runbot_merge_commit
USING hash ( sha )
""" )
2019-03-05 14:01:38 +07:00
self . _cr . execute ( """
CREATE INDEX IF NOT EXISTS runbot_merge_to_process
ON runbot_merge_commit ( ( 1 ) ) WHERE to_check
""" )
2018-03-14 16:37:46 +07:00
return res
class Stagings ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.stagings '
2018-03-14 16:37:46 +07:00
target = fields . Many2one ( ' runbot_merge.branch ' , required = True )
batch_ids = fields . One2many (
' runbot_merge.batch ' , ' staging_id ' ,
2021-08-03 19:14:44 +07:00
context = { ' active_test ' : False } ,
2018-03-14 16:37:46 +07:00
)
state = fields . Selection ( [
( ' success ' , ' Success ' ) ,
( ' failure ' , ' Failure ' ) ,
( ' pending ' , ' Pending ' ) ,
2018-10-01 15:21:32 +07:00
( ' cancelled ' , " Cancelled " ) ,
( ' ff_failed ' , " Fast forward failed " )
2019-09-27 19:59:37 +07:00
] , default = ' pending ' )
2018-06-18 17:59:57 +07:00
active = fields . Boolean ( default = True )
2018-03-14 16:37:46 +07:00
staged_at = fields . Datetime ( default = fields . Datetime . now )
2019-09-23 20:42:18 +07:00
timeout_limit = fields . Datetime ( store = True , compute = ' _compute_timeout_limit ' )
2018-10-01 15:21:32 +07:00
reason = fields . Text ( " Reason for final state (if any) " )
2018-03-14 16:37:46 +07:00
2018-06-18 20:23:23 +07:00
# seems simpler than adding yet another indirection through a model
heads = fields . Char ( required = True , help = " JSON-encoded map of heads, one per repo in the project " )
2019-08-26 21:25:16 +07:00
head_ids = fields . Many2many ( ' runbot_merge.commit ' , compute = ' _compute_statuses ' )
2018-03-14 16:37:46 +07:00
2018-10-19 22:24:01 +07:00
statuses = fields . Binary ( compute = ' _compute_statuses ' )
2022-11-04 21:22:36 +07:00
statuses_cache = fields . Text ( )
def write ( self , vals ) :
# don't allow updating the statuses_cache
vals . pop ( ' statuses_cache ' , None )
if ' state ' not in vals :
return super ( ) . write ( vals )
previously_pending = self . filtered ( lambda s : s . state == ' pending ' )
super ( Stagings , self ) . write ( vals )
for staging in previously_pending :
if staging . state != ' pending ' :
super ( Stagings , staging ) . write ( {
' statuses_cache ' : json . dumps ( staging . statuses )
} )
return True
2018-10-19 22:24:01 +07:00
2022-07-29 18:43:40 +07:00
def name_get ( self ) :
return [
( staging . id , " %d ( %s , %s %s ) " % (
staging . id ,
staging . target . name ,
staging . state ,
( ' , ' + staging . reason ) if staging . reason else ' ' ,
) )
for staging in self
]
2018-10-19 22:24:01 +07:00
@api.depends ( ' heads ' )
def _compute_statuses ( self ) :
""" Fetches statuses associated with the various heads, returned as
( repo , context , state , url )
"""
Commits = self . env [ ' runbot_merge.commit ' ]
for st in self :
heads = {
head : repo for repo , head in json . loads ( st . heads ) . items ( )
if not repo . endswith ( ' ^ ' )
}
2019-08-26 21:25:16 +07:00
commits = st . head_ids = Commits . search ( [ ( ' sha ' , ' in ' , list ( heads . keys ( ) ) ) ] )
2022-11-04 21:22:36 +07:00
if st . statuses_cache :
st . statuses = json . loads ( st . statuses_cache )
continue
2018-10-19 22:24:01 +07:00
st . statuses = [
(
heads [ commit . sha ] ,
context ,
status . get ( ' state ' ) or ' pending ' ,
status . get ( ' target_url ' ) or ' '
)
for commit in commits
for context , st in json . loads ( commit . statuses ) . items ( )
for status in [ to_status ( st ) ]
]
2019-09-23 20:42:18 +07:00
# only depend on staged_at as it should not get modified, but we might
# update the CI timeout after the staging have been created and we
# *do not* want to update the staging timeouts in that case
@api.depends ( ' staged_at ' )
def _compute_timeout_limit ( self ) :
for st in self :
st . timeout_limit = fields . Datetime . to_string (
fields . Datetime . from_string ( st . staged_at )
+ datetime . timedelta ( minutes = st . target . project_id . ci_timeout )
)
2018-03-14 16:37:46 +07:00
def _validate ( self ) :
Commits = self . env [ ' runbot_merge.commit ' ]
for s in self :
2019-09-27 19:59:37 +07:00
if s . state != ' pending ' :
2018-10-01 15:21:32 +07:00
continue
2020-01-21 20:00:11 +07:00
repos = {
repo . name : repo
for repo in self . env [ ' runbot_merge.repository ' ] . search ( [ ] )
2020-02-07 14:22:52 +07:00
. having_branch ( s . target )
2020-01-21 20:00:11 +07:00
}
2020-02-07 14:22:52 +07:00
# maps commits to the statuses they need
required_statuses = [
2020-07-10 17:55:39 +07:00
( head , repos [ repo ] . status_ids . _for_staging ( s ) . mapped ( ' context ' ) )
2020-01-21 20:00:11 +07:00
for repo , head in json . loads ( s . heads ) . items ( )
2018-09-10 21:00:26 +07:00
if not repo . endswith ( ' ^ ' )
2020-02-07 14:22:52 +07:00
]
# maps commits to their statuses
cmap = {
c . sha : json . loads ( c . statuses )
for c in Commits . search ( [ ( ' sha ' , ' in ' , [ h for h , _ in required_statuses ] ) ] )
2020-01-21 20:00:11 +07:00
}
2018-03-14 16:37:46 +07:00
2019-09-23 20:42:18 +07:00
update_timeout_limit = False
2018-03-14 16:37:46 +07:00
st = ' success '
2020-02-07 14:22:52 +07:00
for head , reqs in required_statuses :
statuses = cmap . get ( head ) or { }
2020-02-07 15:54:08 +07:00
for v in map ( lambda n : state_ ( statuses , n ) , reqs ) :
2018-03-14 16:37:46 +07:00
if st == ' failure ' or v in ( ' error ' , ' failure ' ) :
st = ' failure '
2019-09-23 20:42:18 +07:00
elif v is None :
2018-03-14 16:37:46 +07:00
st = ' pending '
2019-09-23 20:42:18 +07:00
elif v == ' pending ' :
st = ' pending '
update_timeout_limit = True
2018-03-14 16:37:46 +07:00
else :
assert v == ' success '
2018-09-10 21:00:26 +07:00
2019-09-23 20:42:18 +07:00
vals = { ' state ' : st }
if update_timeout_limit :
vals [ ' timeout_limit ' ] = fields . Datetime . to_string ( datetime . datetime . now ( ) + datetime . timedelta ( minutes = s . target . project_id . ci_timeout ) )
[IMP] runbot_merge: precisely filter stagings before validating them
Before this, we would "roughly" select stagings by looking at stagings
whose heads matched a specific sha then validating them all. This
could perform extra validations on stagings once in a while but this
was assumed not to be much an issue, at least originally.
However two changes later on have contributed to this likely being the
cause of #429 (stagings never timing out):
* heads of the staging branches are uniquifier commits stored in the
heads map, but the actual heads of the stagings are also stored
there, some of which are no-ops (hence the uniquifiers) so assuming
repos A and B, if a staging contains PRs touching A then the head of
B actual will also be a head of B
* when a staging is validated, if it *contains* any pending result the
timeout limit gets bumped back
The issue here is that if a success / failure status is lost (which
would be the most common reason for timeouts) *and* someone has forked
and is regularly rebuilding a branch-head used as-is by a staging,
each of those rebuilds will trigger a validation of the staging, which
will find that one of the statuses is still pending (because we missed
the success / failure), which will bump up the timeout limit,
continuing until the branch stops getting rebuilt.
This is probably one of the reasons why some stagings last for *way*
more than 2h, though it is far from explaining all of them: 90% of the
stagings lasting more than *3*h end up succeeding. Tho it's always
possible that this is because someone notices and resends a success
for the missing status it seems somewhat doubtful. Oh well.
Also fix the incorrect log call on `update_timeout_limit` triggering.
2021-01-20 15:22:11 +07:00
_logger . debug ( " %s got pending status, bumping timeout to %s ( %s ) " , self , vals [ ' timeout_limit ' ] , cmap )
2019-09-23 20:42:18 +07:00
s . write ( vals )
2018-03-14 16:37:46 +07:00
2019-08-27 17:28:53 +07:00
def action_cancel ( self ) :
2022-12-07 20:45:48 +07:00
w = self . env [ ' runbot_merge.stagings.cancel ' ] . create ( {
' staging_id ' : self . id ,
} )
return {
' type ' : ' ir.actions.act_window ' ,
' target ' : ' new ' ,
' name ' : f ' Cancel staging { self . id } ( { self . target . name } ) ' ,
' view_mode ' : ' form ' ,
' res_model ' : w . _name ,
' res_id ' : w . id ,
}
2019-08-27 17:28:53 +07:00
def cancel ( self , reason , * args ) :
self = self . filtered ( ' active ' )
2018-03-27 18:33:04 +07:00
if not self :
return
2018-09-21 20:58:30 +07:00
_logger . info ( " Cancelling staging %s : " + reason , self , * args )
2019-08-27 17:28:53 +07:00
self . mapped ( ' batch_ids ' ) . write ( { ' active ' : False } )
2018-10-01 15:21:32 +07:00
self . write ( {
' active ' : False ,
' state ' : ' cancelled ' ,
2018-10-08 20:31:07 +07:00
' reason ' : reason % args ,
2018-10-01 15:21:32 +07:00
} )
2018-03-27 18:33:04 +07:00
2018-03-14 16:37:46 +07:00
def fail ( self , message , prs = None ) :
2021-01-13 21:48:39 +07:00
_logger . info ( " Staging %s failed: %s " , self , message )
2018-03-14 16:37:46 +07:00
prs = prs or self . batch_ids . prs
prs . write ( { ' state ' : ' error ' } )
for pr in prs :
2018-10-29 15:42:26 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
2022-06-23 19:25:07 +07:00
' message ' : " %s staging failed: %s " % ( pr . ping ( ) , message ) ,
2018-10-29 15:42:26 +07:00
} )
2018-03-14 16:37:46 +07:00
2018-06-18 17:59:57 +07:00
self . batch_ids . write ( { ' active ' : False } )
2018-10-01 15:21:32 +07:00
self . write ( {
' active ' : False ,
' state ' : ' failure ' ,
' reason ' : message ,
} )
2018-03-14 16:37:46 +07:00
def try_splitting ( self ) :
batches = len ( self . batch_ids )
if batches > 1 :
midpoint = batches / / 2
h , t = self . batch_ids [ : midpoint ] , self . batch_ids [ midpoint : ]
2018-06-18 20:23:23 +07:00
# NB: batches remain attached to their original staging
sh = self . env [ ' runbot_merge.split ' ] . create ( {
2018-03-14 16:37:46 +07:00
' target ' : self . target . id ,
' batch_ids ' : [ ( 4 , batch . id , 0 ) for batch in h ] ,
} )
2018-06-18 20:23:23 +07:00
st = self . env [ ' runbot_merge.split ' ] . create ( {
2018-03-14 16:37:46 +07:00
' target ' : self . target . id ,
' batch_ids ' : [ ( 4 , batch . id , 0 ) for batch in t ] ,
} )
2018-06-18 17:59:57 +07:00
_logger . info ( " Split %s to %s ( %s ) and %s ( %s ) " ,
self , h , sh , t , st )
2018-06-18 20:23:23 +07:00
self . batch_ids . write ( { ' active ' : False } )
2018-10-01 15:21:32 +07:00
self . write ( {
' active ' : False ,
' state ' : ' failure ' ,
' reason ' : self . reason if self . state == ' failure ' else ' timed out '
} )
2018-03-14 16:37:46 +07:00
return True
# single batch => the staging is an unredeemable failure
if self . state != ' failure ' :
# timed out, just mark all PRs (wheee)
2018-03-26 18:08:49 +07:00
self . fail ( ' timed out (> {} minutes) ' . format ( self . target . project_id . ci_timeout ) )
2018-03-14 16:37:46 +07:00
return False
# try inferring which PR failed and only mark that one
for repo , head in json . loads ( self . heads ) . items ( ) :
2018-09-10 21:00:26 +07:00
if repo . endswith ( ' ^ ' ) :
continue
2021-08-11 20:10:22 +07:00
required_statuses = set (
self . env [ ' runbot_merge.repository ' ]
. search ( [ ( ' name ' , ' = ' , repo ) ] )
. status_ids
. _for_staging ( self )
. mapped ( ' context ' ) )
commit = self . env [ ' runbot_merge.commit ' ] . search ( [ ( ' sha ' , ' = ' , head ) ] )
2019-05-07 17:53:55 +07:00
statuses = json . loads ( commit . statuses or ' {} ' )
2018-03-14 16:37:46 +07:00
reason = next ( (
2018-09-17 16:04:31 +07:00
ctx for ctx , result in statuses . items ( )
2021-08-11 20:10:22 +07:00
if ctx in required_statuses
2018-09-17 16:04:31 +07:00
if to_status ( result ) . get ( ' state ' ) in ( ' error ' , ' failure ' )
2018-03-14 16:37:46 +07:00
) , None )
if not reason :
continue
pr = next ( (
pr for pr in self . batch_ids . prs
if pr . repository . name == repo
) , None )
2018-09-17 16:04:31 +07:00
status = to_status ( statuses [ reason ] )
viewmore = ' '
if status . get ( ' target_url ' ) :
viewmore = ' (view more at %(target_url)s ) ' % status
2018-03-14 16:37:46 +07:00
if pr :
2018-09-17 16:04:31 +07:00
self . fail ( " %s %s " % ( reason , viewmore ) , pr )
2018-09-10 22:18:25 +07:00
else :
2018-09-17 16:04:31 +07:00
self . fail ( ' %s on %s %s ' % ( reason , head , viewmore ) )
2018-09-10 22:18:25 +07:00
return False
2018-03-14 16:37:46 +07:00
# the staging failed but we don't have a specific culprit, fail
# everything
self . fail ( " unknown reason " )
return False
2018-10-12 21:15:37 +07:00
def check_status ( self ) :
"""
Checks the status of an active staging :
* merges it if successful
* splits it if failed ( or timed out ) and more than 1 batch
* marks the PRs as failed otherwise
* ignores if pending ( or cancelled or ff_failed but those should also
be disabled )
"""
logger = _logger . getChild ( ' cron ' )
if not self . active :
logger . info ( " Staging %s is not active, ignoring status check " , self )
return
logger . info ( " Checking active staging %s (state= %s ) " , self , self . state )
project = self . target . project_id
if self . state == ' success ' :
2020-01-22 13:52:10 +07:00
gh = { repo . name : repo . github ( ) for repo in project . repo_ids . having_branch ( self . target ) }
2018-10-12 21:15:37 +07:00
staging_heads = json . loads ( self . heads )
2019-03-01 14:52:42 +07:00
self . env . cr . execute ( '''
SELECT 1 FROM runbot_merge_pull_requests
WHERE id in % s
FOR UPDATE
''' , [tuple(self.mapped( ' batch_ids.prs.id ' ))])
2018-10-12 21:15:37 +07:00
try :
2019-08-26 18:41:11 +07:00
self . _safety_dance ( gh , staging_heads )
2018-10-12 21:15:37 +07:00
except exceptions . FastForwardError as e :
logger . warning (
" Could not fast-forward successful staging on %s : %s " ,
2019-08-26 18:41:11 +07:00
e . args [ 0 ] , self . target . name ,
2018-10-12 21:15:37 +07:00
exc_info = True
)
self . write ( {
' state ' : ' ff_failed ' ,
2020-03-12 19:45:21 +07:00
' reason ' : str ( e . __cause__ or e . __context__ or e )
2018-10-12 21:15:37 +07:00
} )
else :
prs = self . mapped ( ' batch_ids.prs ' )
logger . info (
" %s FF successful, marking %s as merged " ,
self , prs
)
prs . write ( { ' state ' : ' merged ' } )
2021-11-12 22:04:34 +07:00
2021-03-01 22:18:14 +07:00
pseudobranch = None
2021-11-12 22:04:34 +07:00
if self . target == project . branch_ids [ : 1 ] :
pseudobranch = project . _next_freeze ( )
2021-03-01 22:18:14 +07:00
2018-10-12 21:15:37 +07:00
for pr in prs :
[IMP] runbot_merge: concurrency issue with GH closing PRs being merged
Once more unto the breach, with the issue of pushing stagings (with
"closes" annotations) to the target branch making GH close the PR &
send the hook, which makes runbot_merge consider the PR closed and the
staging cancelled.
This probably still doesn't fix the issue, but it reduces the
problematic window: before this, the process first updates the
branches, then marks the PRs, then comments & closes the PRs, and
finally commits the PR update.
This means as runbot_merge is sending a comment & a status update to
each PR in a staging, GH has some time to send the "closed" webhook
behind its back, making the controller immediately cancel the current
staging, especially if the v3 endpoint is a bit slow.
By moving the commenting & closing out of the critical path (to the
feedback queue), this window should be significantly shortened.
2018-10-24 21:14:31 +07:00
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
[IMP] runbot_merge: limit spamming on PR close
When closing a PR, github completely separates the events "close the
PR" and "comment on the PR" (even when using "comment and close" in
the UI, a feature which isn't even available in the API). It doesn't
aggregate the notifications either, so users following the PR for
one reason or another get 2 notifications / mails every time a PR
gets merged, which is a lot of traffic, even more so with
forward-ported PRs multiplying the amount of PRs users are involved
in.
The comment on top of the closure itself is useful though: it allows
tracking exactly where and how the PR was merged from the PR, this
information should not be lost.
While more involved than a simple comment, *deployments* seem like
a suitable solution: they allow providing links as permanent
information / metadata on the PRs, and apparently don't trigger
notifications to users.
Therefore, modify the "close" method so it doesn't do
"comment-and-close", and provide a way to close PRs with non-comment
feedback: when the feedback's message is structured (parsable as
json) assume it's intended as deployment-bound notifications.
TODO: maybe add more keys to the feedback event payload, though in my
tests (odoo/runbot#222) none of the deployment metadata
outside of "environment" and "target_url" is listed on the PR
UI
Fixes #224
2019-11-19 16:04:44 +07:00
' message ' : json . dumps ( {
' sha ' : json . loads ( pr . commits_map ) [ ' ' ] ,
} ) ,
[IMP] runbot_merge: concurrency issue with GH closing PRs being merged
Once more unto the breach, with the issue of pushing stagings (with
"closes" annotations) to the target branch making GH close the PR &
send the hook, which makes runbot_merge consider the PR closed and the
staging cancelled.
This probably still doesn't fix the issue, but it reduces the
problematic window: before this, the process first updates the
branches, then marks the PRs, then comments & closes the PRs, and
finally commits the PR update.
This means as runbot_merge is sending a comment & a status update to
each PR in a staging, GH has some time to send the "closed" webhook
behind its back, making the controller immediately cancel the current
staging, especially if the v3 endpoint is a bit slow.
By moving the commenting & closing out of the critical path (to the
feedback queue), this window should be significantly shortened.
2018-10-24 21:14:31 +07:00
' close ' : True ,
} )
2021-03-01 22:18:14 +07:00
if pseudobranch :
self . env [ ' runbot_merge.pull_requests.tagging ' ] . create ( {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
' tags_add ' : json . dumps ( [ pseudobranch ] ) ,
} )
2018-10-12 21:15:37 +07:00
finally :
self . batch_ids . write ( { ' active ' : False } )
self . write ( { ' active ' : False } )
2021-11-10 19:13:34 +07:00
elif self . state == ' failure ' or self . is_timed_out ( ) :
2018-10-12 21:15:37 +07:00
self . try_splitting ( )
2021-11-10 19:13:34 +07:00
def is_timed_out ( self ) :
return fields . Datetime . from_string ( self . timeout_limit ) < datetime . datetime . now ( )
2019-02-28 22:09:07 +07:00
def _safety_dance ( self , gh , staging_heads ) :
""" Reverting updates doesn ' t work if the branches are protected
( because a revert is basically a force push ) . So we can update
REPO_A , then fail to update REPO_B for some reason , and we ' re hosed.
To try and make this issue less likely , do the safety dance :
* First , perform a dry run using the tmp branches ( which can be
force - pushed and sacrificed ) , that way if somebody pushed directly
to REPO_B during the staging we catch it . If we ' re really unlucky
they could still push after the dry run but . . .
* An other issue then is that the github call sometimes fails for no
noticeable reason ( e . g . network failure or whatnot ) , if it fails
on REPO_B when REPO_A has already been updated things get pretty
bad . In that case , wait a bit and retry for now . A more complex
strategy ( including disabling the branch entirely until somebody
has looked at and fixed the issue ) might be necessary .
: returns : the last repo it tried to update ( probably the one on which
it failed , if it failed )
"""
# FIXME: would make sense for FFE to be richer, and contain the repo name
repo_name = None
tmp_target = ' tmp. ' + self . target . name
# first force-push the current targets to all tmps
for repo_name in staging_heads . keys ( ) :
if repo_name . endswith ( ' ^ ' ) :
continue
g = gh [ repo_name ]
g . set_ref ( tmp_target , g . head ( self . target . name ) )
# then attempt to FF the tmp to the staging
for repo_name , head in staging_heads . items ( ) :
if repo_name . endswith ( ' ^ ' ) :
continue
gh [ repo_name ] . fast_forward ( tmp_target , staging_heads . get ( repo_name + ' ^ ' ) or head )
# there is still a race condition here, but it's way
# lower than "the entire staging duration"...
first = True
for repo_name , head in staging_heads . items ( ) :
if repo_name . endswith ( ' ^ ' ) :
continue
for pause in [ 0.1 , 0.3 , 0.5 , 0.9 , 0 ] : # last one must be 0/falsy of we lose the exception
try :
# if the staging has a $repo^ head, merge that,
# otherwise merge the regular (CI'd) head
gh [ repo_name ] . fast_forward (
self . target . name ,
staging_heads . get ( repo_name + ' ^ ' ) or head
)
except exceptions . FastForwardError :
# The GH API regularly fails us. If the failure does not
# occur on the first repository, retry a few times with a
# little pause.
if not first and pause :
time . sleep ( pause )
continue
raise
[IMP] runbot_merge: sanity check PATCH git/ref/heads
Turns out not only can that operation fail, that operation can succeed
but have its effect delayed. To try and guard against that,
immediately check that we get the correct ref' after having reset it.
This is the cause of the November 6 mess: when preparing a staging,
the mergebot does the following,
1. get the head of <branch>
2. hard-reset tmp.<branch> to that
3. start merging PRs, which requires getting the current state of
tmp.<branch> back
On the 6ths, these steps looked like this
```text
2019-11-06 10:03:21,588 head(odoo/odoo, master) -> ab6d0c38512e4944458b0b6f80f38d6c26b6b597
2019-11-06 10:03:22,375 set_ref(update, odoo/odoo, tmp.master, ab6d0c38512e4944458b0b6f80f38d6c26b6b597 -> 200 (OK)
2019-11-06 10:03:28,674 head(odoo/odoo, tmp.master) -> de2a852e7cc1f390e50190cfc497bc253687fba8
2019-11-06 10:03:30,292 head(odoo/odoo, tmp.master) -> de2a852e7cc1f390e50190cfc497bc253687fba8
```
So the 'bot fetched the commit at the head of master (ab6d0c), reset
tmp.master to that... and then got a different commit when it fetched
the tmp head to stage a PR on it.
That different head being of course a previous rejected staging. When
the new staging succeeded, it brought the entire thing in and made a
mess.
This was compounded by an issue I still have to investigate: the
staging of the new PR took the wrong base commit *but the right base
tree*, as a result the first thing it did was *reverse the entire
previous commit* (without that we could probably have left it as-is
rather than need to force-push master -- twice).
2019-11-07 13:39:20 +07:00
else :
break
2019-02-28 22:09:07 +07:00
first = False
return repo_name
2018-06-18 20:23:23 +07:00
class Split ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.split '
2018-06-18 20:23:23 +07:00
target = fields . Many2one ( ' runbot_merge.branch ' , required = True )
batch_ids = fields . One2many ( ' runbot_merge.batch ' , ' split_id ' , context = { ' active_test ' : False } )
2018-03-14 16:37:46 +07:00
class Batch ( models . Model ) :
""" A batch is a " horizontal " grouping of *codependent* PRs: PRs with
the same label & target but for different repositories . These are
assumed to be part of the same " change " smeared over multiple
repositories e . g . change an API in repo1 , this breaks use of that API
in repo2 which now needs to be updated .
"""
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.batch '
2018-03-14 16:37:46 +07:00
target = fields . Many2one ( ' runbot_merge.branch ' , required = True )
staging_id = fields . Many2one ( ' runbot_merge.stagings ' )
2018-06-18 20:23:23 +07:00
split_id = fields . Many2one ( ' runbot_merge.split ' )
2018-03-14 16:37:46 +07:00
2018-06-18 17:59:57 +07:00
prs = fields . Many2many ( ' runbot_merge.pull_requests ' )
active = fields . Boolean ( default = True )
2018-03-14 16:37:46 +07:00
@api.constrains ( ' target ' , ' prs ' )
def _check_prs ( self ) :
for batch in self :
repos = self . env [ ' runbot_merge.repository ' ]
for pr in batch . prs :
if pr . target != batch . target :
raise ValidationError ( " A batch and its PRs must have the same branch, got %s and %s " % ( batch . target , pr . target ) )
if pr . repository in repos :
raise ValidationError ( " All prs of a batch must have different target repositories, got a duplicate %s on %s " % ( pr . repository , pr ) )
repos | = pr . repository
def stage ( self , meta , prs ) :
"""
Updates meta [ * ] [ head ] on success
: return : ( ) or Batch object ( if all prs successfully staged )
"""
new_heads = { }
for pr in prs :
gh = meta [ pr . repository ] [ ' gh ' ]
_logger . info (
2022-06-29 16:11:59 +07:00
" Staging pr %s for target %s ; method= %s " ,
pr . display_name , pr . target . name ,
pr . merge_method or ( pr . squash and ' single ' ) or None
2018-03-14 16:37:46 +07:00
)
2018-03-27 16:24:40 +07:00
2018-08-29 21:51:53 +07:00
target = ' tmp. {} ' . format ( pr . target . name )
2018-09-20 15:04:13 +07:00
original_head = gh . head ( target )
2018-03-14 16:37:46 +07:00
try :
2021-08-03 18:45:21 +07:00
try :
method , new_heads [ pr ] = pr . _stage ( gh , target , related_prs = ( prs - pr ) )
_logger . info (
" Staged pr %s to %s by %s : %s -> %s " ,
pr . display_name , pr . target . name , method ,
original_head , new_heads [ pr ]
2019-11-20 20:57:40 +07:00
)
2021-08-03 18:45:21 +07:00
except Exception :
# reset the head which failed, as rebase() may have partially
# updated it (despite later steps failing)
gh . set_ref ( target , original_head )
# then reset every previous update
for to_revert in new_heads . keys ( ) :
it = meta [ to_revert . repository ]
it [ ' gh ' ] . set_ref ( ' tmp. {} ' . format ( to_revert . target . name ) , it [ ' head ' ] )
raise
except github . MergeError :
raise exceptions . MergeError ( pr )
except exceptions . Mismatch as e :
old_head , new_head , to_squash = e . args
pr . write ( {
' state ' : ' opened ' ,
' squash ' : to_squash ,
' head ' : new_head ,
} )
_logger . warning (
" head mismatch on %s : had %s but found %s " ,
pr . display_name , old_head , new_head
)
self . env [ ' runbot_merge.pull_requests.feedback ' ] . create ( {
' repository ' : pr . repository . id ,
' pull_request ' : pr . number ,
2022-06-23 19:25:07 +07:00
' message ' : " %s we apparently missed an update to this PR "
2021-08-03 18:45:21 +07:00
" and tried to stage it in a state which "
" might not have been approved. PR has been "
" updated to %s , please check and approve or "
2022-06-23 19:25:07 +07:00
" re-approve. " % ( pr . ping ( ) , new_head )
2021-08-03 18:45:21 +07:00
} )
return self . env [ ' runbot_merge.batch ' ]
2018-03-14 16:37:46 +07:00
# update meta to new heads
for pr , head in new_heads . items ( ) :
meta [ pr . repository ] [ ' head ' ] = head
return self . create ( {
' target ' : prs [ 0 ] . target . id ,
' prs ' : [ ( 4 , pr . id , 0 ) for pr in prs ] ,
} )
2018-06-21 14:55:14 +07:00
class FetchJob ( models . Model ) :
2020-01-13 14:40:12 +07:00
_name = _description = ' runbot_merge.fetch_job '
2018-06-21 14:55:14 +07:00
active = fields . Boolean ( default = True )
2019-11-20 20:57:40 +07:00
repository = fields . Many2one ( ' runbot_merge.repository ' , required = True )
2022-12-07 21:13:55 +07:00
number = fields . Integer ( required = True , group_operator = None )
2018-09-17 16:04:31 +07:00
2021-11-10 19:13:34 +07:00
def _check ( self , commit = False ) :
"""
: param bool commit : commit after each fetch has been executed
"""
while True :
f = self . search ( [ ] , limit = 1 )
if not f :
return
self . env . cr . execute ( " SAVEPOINT runbot_merge_before_fetch " )
try :
f . repository . _load_pr ( f . number )
except Exception :
self . env . cr . execute ( " ROLLBACK TO SAVEPOINT runbot_merge_before_fetch " )
_logger . exception ( " Failed to load pr %s , skipping it " , f . number )
finally :
self . env . cr . execute ( " RELEASE SAVEPOINT runbot_merge_before_fetch " )
f . active = False
if commit :
self . env . cr . commit ( )
2018-09-17 16:04:31 +07:00
# The commit (and PR) statuses was originally a map of ``{context:state}``
# however it turns out to clarify error messages it'd be useful to have
# a bit more information e.g. a link to the CI's build info on failure and
# all that. So the db-stored statuses are now becoming a map of
# ``{ context: {state, target_url, description } }``. The issue here is
# there's already statuses stored in the db so we need to handle both
# formats, hence these utility functions)
def state_ ( statuses , name ) :
""" Fetches the status state """
name = name . strip ( )
v = statuses . get ( name )
if isinstance ( v , dict ) :
return v . get ( ' state ' )
return v
def to_status ( v ) :
""" Converts old-style status values (just a state string) to new-style
( ` ` { state , target_url , description } ` ` )
: type v : str | dict
: rtype : dict
"""
if isinstance ( v , dict ) :
return v
return { ' state ' : v , ' target_url ' : None , ' description ' : None }
2019-04-29 17:42:54 +07:00
2022-07-19 21:02:20 +07:00
refline = re . compile ( rb ' ([ \ da-f] {40} ) ([^ \ 0 \ n]+)( \ 0.*)? \ n?$ ' )
2019-04-29 17:42:54 +07:00
ZERO_REF = b ' 0 ' * 40
def parse_refs_smart ( read ) :
""" yields pkt-line data (bytes), or None for flush lines """
def read_line ( ) :
length = int ( read ( 4 ) , 16 )
if length == 0 :
return None
return read ( length - 4 )
header = read_line ( )
2022-07-11 13:17:04 +07:00
assert header . rstrip ( ) == b ' # service=git-upload-pack ' , header
assert read_line ( ) is None , " failed to find first flush line "
2019-04-29 17:42:54 +07:00
# read lines until second delimiter
for line in iter ( read_line , None ) :
if line . startswith ( ZERO_REF ) :
break # empty list (no refs)
m = refline . match ( line )
yield m [ 1 ] . decode ( ) , m [ 2 ] . decode ( )
2019-08-23 21:16:30 +07:00
2021-01-12 18:24:34 +07:00
BREAK = re . compile ( r '''
^
[ ] { 0 , 3 } # 0-3 spaces of indentation
# followed by a sequence of three or more matching -, _, or * characters,
# each followed optionally by any number of spaces or tabs
# so needs to start with a _, - or *, then have at least 2 more such
# interspersed with any number of spaces or tabs
( [ * _ - ] )
( [ \t ] * \1 ) { 2 , }
[ \t ] *
$
''' , flags=re.VERBOSE)
SETEX_UNDERLINE = re . compile ( r '''
^
[ ] { 0 , 3 } # no more than 3 spaces indentation
[ - = ] + # a sequence of = characters or a sequence of - characters
[ ] * # any number of trailing spaces
$
# we don't care about "a line containing a single -" because we want to
# disambiguate SETEX headings from thematic breaks, and thematic breaks have
# 3+ -. Doesn't look like GH interprets `- - -` as a line so yay...
''' , flags=re.VERBOSE)
2019-08-23 21:16:30 +07:00
HEADER = re . compile ( ' ^([A-Za-z-]+): (.*)$ ' )
class Message :
@classmethod
def from_message ( cls , msg ) :
in_headers = True
2021-01-12 18:24:34 +07:00
maybe_setex = None
2021-01-21 19:15:32 +07:00
# creating from PR message -> remove content following break
msg , handle_break = ( msg , False ) if isinstance ( msg , str ) else ( msg . message , True )
2019-08-23 21:16:30 +07:00
headers = [ ]
body = [ ]
2021-10-20 14:46:53 +07:00
# don't process the title (first line) of the commit message
msg = msg . splitlines ( )
for line in reversed ( msg [ 1 : ] ) :
2021-01-12 18:24:34 +07:00
if maybe_setex :
# NOTE: actually slightly more complicated: it's a SETEX heading
# only if preceding line(s) can be interpreted as a
# paragraph so e.g. a title followed by a line of dashes
# would indeed be a break, but this should be good enough
# for now, if we need more we'll need a full-blown
# markdown parser probably
if line : # actually a SETEX title -> add underline to body then process current
body . append ( maybe_setex )
else : # actually break, remove body then process current
body = [ ]
maybe_setex = None
2019-08-23 21:16:30 +07:00
if not line :
if not in_headers and body and body [ - 1 ] :
body . append ( line )
continue
2021-01-21 19:15:32 +07:00
if handle_break and BREAK . match ( line ) :
2021-01-12 18:24:34 +07:00
if SETEX_UNDERLINE . match ( line ) :
maybe_setex = line
else :
body = [ ]
continue
2019-08-23 21:16:30 +07:00
h = HEADER . match ( line )
if h :
# c-a-b = special case from an existing test, not sure if actually useful?
if in_headers or h . group ( 1 ) . lower ( ) == ' co-authored-by ' :
headers . append ( h . groups ( ) )
continue
body . append ( line )
in_headers = False
2021-10-20 14:46:53 +07:00
# if there are non-title body lines, add a separation after the title
if body and body [ - 1 ] :
body . append ( ' ' )
body . append ( msg [ 0 ] )
2019-08-23 21:16:30 +07:00
return cls ( ' \n ' . join ( reversed ( body ) ) , Headers ( reversed ( headers ) ) )
def __init__ ( self , body , headers = None ) :
self . body = body
self . headers = headers or Headers ( )
def __setattr__ ( self , name , value ) :
# make sure stored body is always stripped
if name == ' body ' :
value = value and value . strip ( )
super ( ) . __setattr__ ( name , value )
def __str__ ( self ) :
if not self . headers :
return self . body + ' \n '
with io . StringIO ( self . body ) as msg :
msg . write ( self . body )
msg . write ( ' \n \n ' )
# https://git.wiki.kernel.org/index.php/CommitMessageConventions
# seems to mostly use capitalised names (rather than title-cased)
keys = list ( OrderedSet ( k . capitalize ( ) for k in self . headers . keys ( ) ) )
# c-a-b must be at the very end otherwise github doesn't see it
keys . sort ( key = lambda k : k == ' Co-authored-by ' )
for k in keys :
for v in self . headers . getlist ( k ) :
msg . write ( k )
msg . write ( ' : ' )
msg . write ( v )
msg . write ( ' \n ' )
return msg . getvalue ( )
def sub ( self , pattern , repl , * , flags ) :
""" Performs in-place replacements on the body
"""
self . body = re . sub ( pattern , repl , self . body , flags = flags )